mirror of
https://github.com/nitnelave/lldap.git
synced 2023-04-12 14:25:13 +00:00
Merge branch 'main' into feature/avatar-display
This commit is contained in:
commit
970af4f01a
|
@ -13,12 +13,10 @@ RUN groupadd --gid $USER_GID $USERNAME \
|
||||||
&& chmod 0440 /etc/sudoers.d/$USERNAME
|
&& chmod 0440 /etc/sudoers.d/$USERNAME
|
||||||
|
|
||||||
RUN apt update && \
|
RUN apt update && \
|
||||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
apt install -y --no-install-recommends libssl-dev musl-dev make perl curl gzip && \
|
||||||
apt update && \
|
rm -rf /var/lib/apt/lists/*
|
||||||
apt install -y --no-install-recommends nodejs libssl-dev musl-dev make perl curl
|
|
||||||
|
|
||||||
RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
|
RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
|
||||||
&& npm install -g rollup \
|
|
||||||
&& rustup target add wasm32-unknown-unknown
|
&& rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
USER $USERNAME
|
USER $USERNAME
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
{
|
{
|
||||||
"name": "LLDAP dev",
|
"name": "LLDAP dev",
|
||||||
"build": { "dockerfile": "Dockerfile" },
|
"build": { "dockerfile": "Dockerfile" },
|
||||||
"forwardPorts": [3890, 17170]
|
"forwardPorts": [
|
||||||
|
3890,
|
||||||
|
17170
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
25
.github/workflows/Dockerfile.ci.alpine
vendored
25
.github/workflows/Dockerfile.ci.alpine
vendored
|
@ -10,28 +10,34 @@ RUN mkdir -p target/
|
||||||
RUN mkdir -p /lldap/app
|
RUN mkdir -p /lldap/app
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||||
mv bin/amd64-bin/lldap target/lldap && \
|
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool target/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 && \
|
||||||
chmod +x target/migration-tool && \
|
chmod +x target/migration-tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
ls -la target/ . && \
|
ls -la target/ . && \
|
||||||
pwd \
|
pwd \
|
||||||
; fi
|
; fi
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||||
mv bin/aarch64-bin/lldap target/lldap && \
|
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool target/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 && \
|
||||||
chmod +x target/migration-tool && \
|
chmod +x target/migration-tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
ls -la target/ . && \
|
ls -la target/ . && \
|
||||||
pwd \
|
pwd \
|
||||||
; fi
|
; fi
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||||
mv bin/armhf-bin/lldap target/lldap && \
|
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap target/lldap && \
|
||||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool target/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 && \
|
||||||
chmod +x target/migration-tool && \
|
chmod +x target/migration-tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
ls -la target/ . && \
|
ls -la target/ . && \
|
||||||
pwd \
|
pwd \
|
||||||
; fi
|
; fi
|
||||||
|
@ -42,6 +48,7 @@ COPY lldap_config.docker_template.toml /lldap/
|
||||||
COPY web/index_local.html web/index.html
|
COPY web/index_local.html web/index.html
|
||||||
RUN cp target/lldap /lldap/ && \
|
RUN cp target/lldap /lldap/ && \
|
||||||
cp target/migration-tool /lldap/ && \
|
cp target/migration-tool /lldap/ && \
|
||||||
|
cp target/lldap_set_password /lldap/ && \
|
||||||
cp -R web/index.html \
|
cp -R web/index.html \
|
||||||
web/pkg \
|
web/pkg \
|
||||||
web/static \
|
web/static \
|
||||||
|
@ -98,10 +105,10 @@ RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
||||||
"$USER" && \
|
"$USER" && \
|
||||||
mkdir -p /data && \
|
mkdir -p /data && \
|
||||||
chown $USER:$USER /data
|
chown $USER:$USER /data
|
||||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
|
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
|
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
HEALTHCHECK CMD ["/app/lldap", "run", "--config-file", "/data/lldap_config.toml"]
|
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
|
21
.github/workflows/Dockerfile.ci.debian
vendored
21
.github/workflows/Dockerfile.ci.debian
vendored
|
@ -10,28 +10,34 @@ RUN mkdir -p target/
|
||||||
RUN mkdir -p /lldap/app
|
RUN mkdir -p /lldap/app
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||||
mv bin/amd64-bin/lldap target/lldap && \
|
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool target/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 && \
|
||||||
chmod +x target/migration-tool && \
|
chmod +x target/migration-tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
ls -la target/ . && \
|
ls -la target/ . && \
|
||||||
pwd \
|
pwd \
|
||||||
; fi
|
; fi
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||||
mv bin/aarch64-bin/lldap target/lldap && \
|
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool target/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 && \
|
||||||
chmod +x target/migration-tool && \
|
chmod +x target/migration-tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
ls -la target/ . && \
|
ls -la target/ . && \
|
||||||
pwd \
|
pwd \
|
||||||
; fi
|
; fi
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||||
mv bin/armhf-bin/lldap target/lldap && \
|
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap target/lldap && \
|
||||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool target/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 && \
|
||||||
chmod +x target/migration-tool && \
|
chmod +x target/migration-tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
ls -la target/ . && \
|
ls -la target/ . && \
|
||||||
pwd \
|
pwd \
|
||||||
; fi
|
; fi
|
||||||
|
@ -42,6 +48,7 @@ COPY lldap_config.docker_template.toml /lldap/
|
||||||
COPY web/index_local.html web/index.html
|
COPY web/index_local.html web/index.html
|
||||||
RUN cp target/lldap /lldap/ && \
|
RUN cp target/lldap /lldap/ && \
|
||||||
cp target/migration-tool /lldap/ && \
|
cp target/migration-tool /lldap/ && \
|
||||||
|
cp target/lldap_set_password /lldap/ && \
|
||||||
cp -R web/index.html \
|
cp -R web/index.html \
|
||||||
web/pkg \
|
web/pkg \
|
||||||
web/static \
|
web/static \
|
||||||
|
@ -69,4 +76,4 @@ VOLUME ["/data"]
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
HEALTHCHECK CMD ["/app/lldap", "run", "--config-file", "/data/lldap_config.toml"]
|
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
|
25
.github/workflows/Dockerfile.dev
vendored
25
.github/workflows/Dockerfile.dev
vendored
|
@ -1,28 +1,34 @@
|
||||||
FROM rust:1.65-slim-bullseye
|
# Keep tracking base image
|
||||||
|
FROM rust:1.66-slim-bullseye
|
||||||
|
|
||||||
# Set needed env path
|
# Set needed env path
|
||||||
ENV PATH="/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
ENV PATH="/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||||
|
|
||||||
### Install build deps x86_64
|
### Install build deps x86_64
|
||||||
RUN apt update && \
|
RUN apt update && \
|
||||||
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools && \
|
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 - && \
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||||
apt update && \
|
apt update && \
|
||||||
apt install -y --no-install-recommends nodejs && \
|
apt install -y --no-install-recommends nodejs && \
|
||||||
apt clean && \
|
apt clean && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/*
|
||||||
npm install -g npm && \
|
|
||||||
npm install -g yarn && \
|
|
||||||
npm install -g pnpm
|
|
||||||
|
|
||||||
### Install build deps aarch64 build
|
### Install build deps aarch64 build
|
||||||
RUN dpkg --add-architecture arm64 && \
|
RUN dpkg --add-architecture arm64 && \
|
||||||
apt update && \
|
apt update && \
|
||||||
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross && \
|
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross gzip && \
|
||||||
apt clean && \
|
apt clean && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
rustup target add aarch64-unknown-linux-gnu
|
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
|
### 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 && \
|
||||||
|
@ -31,4 +37,9 @@ RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
||||||
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
|
||||||
|
|
||||||
|
### Add musl target
|
||||||
|
RUN rustup target add x86_64-unknown-linux-musl && \
|
||||||
|
rustup target add aarch64-unknown-linux-musl
|
||||||
|
|
||||||
|
|
||||||
CMD ["bash"]
|
CMD ["bash"]
|
||||||
|
|
501
.github/workflows/docker-build-static.yml
vendored
501
.github/workflows/docker-build-static.yml
vendored
|
@ -19,55 +19,54 @@ on:
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
# In total 5 jobs, all the jobs are containerized
|
|
||||||
# ---
|
### CI Docs
|
||||||
|
|
||||||
# build-ui , create/compile the web
|
# build-ui , create/compile the web
|
||||||
## Use rustlang/rust:nighlty image
|
|
||||||
### Install nodejs from nodesource repo
|
|
||||||
### install wasm
|
### install wasm
|
||||||
### install rollup
|
### install rollup
|
||||||
### run app/build.sh
|
### run app/build.sh
|
||||||
### upload artifacts
|
### upload artifacts
|
||||||
|
|
||||||
# builds-armhf, build-aarch64, build-amd64 create binary for respective arch
|
# build-bin
|
||||||
## Use rustlang/rust:nightly image
|
## build-armhf, build-aarch64, build-amd64 , create binary for respective arch
|
||||||
### Add non-native architecture dpkg --add-architecture XXX
|
#######################################################################################
|
||||||
### Install dev tool gcc g++, etc. per respective arch
|
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
|
||||||
|
# Look into .github/workflows/Dockerfile.dev for development image details #
|
||||||
|
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
|
||||||
|
#######################################################################################
|
||||||
### Cargo build
|
### Cargo build
|
||||||
### Upload artifacts
|
### 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
|
||||||
## the CARGO_ env
|
|
||||||
#CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
|
||||||
# This will determine which architecture lib will be used.
|
|
||||||
|
|
||||||
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
||||||
|
|
||||||
|
# lldap-test
|
||||||
|
### will run lldap with postgres, mariadb and sqlite backend, do selfcheck command.
|
||||||
|
|
||||||
|
# Build docker image
|
||||||
|
### Triplet docker image arch with debian 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
|
||||||
|
|
||||||
# On current https://hub.docker.com/_/rust
|
# create release artifacts
|
||||||
# 1-bullseye, 1.61-bullseye, 1.61.0-bullseye, bullseye, 1, 1.61, 1.61.0, latest
|
### Fetch artifacts
|
||||||
|
### Clean up web artifact
|
||||||
|
### Setup folder structure
|
||||||
|
### Compress
|
||||||
|
### Upload
|
||||||
|
|
||||||
# cache
|
# cache based on Cargo.lock per cargo target
|
||||||
## cargo
|
|
||||||
## target
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-ui:
|
build-ui:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: rust:1.65
|
image: nitnelave/rust-dev:latest
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
|
||||||
steps:
|
steps:
|
||||||
- name: install runtime
|
- name: Checkout repository
|
||||||
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev ca-certificates
|
uses: actions/checkout@v3.4.0
|
||||||
- name: setup node repo LTS
|
|
||||||
run: curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
|
||||||
- name: install nodejs
|
|
||||||
run: apt install -y nodejs && npm -g install npm
|
|
||||||
- name: smoke test
|
|
||||||
run: rustc --version
|
|
||||||
- uses: actions/cache@v3
|
- uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
|
@ -79,142 +78,42 @@ jobs:
|
||||||
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
lldap-ui-
|
lldap-ui-
|
||||||
- name: Checkout repository
|
- name: Install rollup (nodejs)
|
||||||
uses: actions/checkout@v3.2.0
|
|
||||||
- name: install rollup nodejs
|
|
||||||
run: npm install -g rollup
|
run: npm install -g rollup
|
||||||
- name: install wasm-pack with cargo
|
- name: Add wasm target (rust)
|
||||||
|
run: rustup target add wasm32-unknown-unknown
|
||||||
|
- name: Install wasm-pack with cargo
|
||||||
run: cargo install wasm-pack || true
|
run: cargo install wasm-pack || true
|
||||||
env:
|
env:
|
||||||
RUSTFLAGS: ""
|
RUSTFLAGS: ""
|
||||||
- name: build frontend
|
- name: Build frontend
|
||||||
run: ./app/build.sh
|
run: ./app/build.sh
|
||||||
- name: check 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@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ui
|
name: ui
|
||||||
path: app/
|
path: app/
|
||||||
|
|
||||||
build-armhf:
|
|
||||||
|
build-bin:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
target: [armv7-unknown-linux-gnueabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
||||||
container:
|
container:
|
||||||
image: rust:1.65
|
image: nitnelave/rust-dev:latest
|
||||||
env:
|
env:
|
||||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-gnueabihf-ld
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
|
||||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
|
||||||
steps:
|
|
||||||
- name: add armhf architecture
|
|
||||||
run: dpkg --add-architecture armhf
|
|
||||||
- name: install runtime
|
|
||||||
run: apt update && apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross tar ca-certificates
|
|
||||||
- name: smoke test
|
|
||||||
run: rustc --version
|
|
||||||
- name: add armhf target
|
|
||||||
run: rustup target add armv7-unknown-linux-gnueabihf
|
|
||||||
- name: smoke test
|
|
||||||
run: rustc --version
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v3.2.0
|
|
||||||
- uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
.cargo/bin
|
|
||||||
.cargo/registry/index
|
|
||||||
.cargo/registry/cache
|
|
||||||
.cargo/git/db
|
|
||||||
target
|
|
||||||
key: lldap-bin-armhf-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
lldap-bin-armhf-
|
|
||||||
- name: compile armhf
|
|
||||||
run: cargo build --target=armv7-unknown-linux-gnueabihf --release -p lldap -p migration-tool
|
|
||||||
- name: check path
|
|
||||||
run: ls -al target/release
|
|
||||||
- name: upload armhf lldap artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: armhf-lldap-bin
|
|
||||||
path: target/armv7-unknown-linux-gnueabihf/release/lldap
|
|
||||||
- name: upload armhfmigration-tool artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: armhf-migration-tool-bin
|
|
||||||
path: target/armv7-unknown-linux-gnueabihf/release/migration-tool
|
|
||||||
|
|
||||||
|
|
||||||
build-aarch64:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
##################################################################################
|
|
||||||
# GitHub actions currently timeout when downloading musl-gcc #
|
|
||||||
# Using lldap dev image based on rust:1.65-slim-bullseye and musl-gcc bundled #
|
|
||||||
# Only for Job build aarch64 and amd64 #
|
|
||||||
###################################################################################
|
|
||||||
#image: rust:1.65
|
|
||||||
image: nitnelave/rust-dev:latest
|
|
||||||
env:
|
|
||||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
|
||||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v3.2.0
|
|
||||||
- name: smoke test
|
|
||||||
run: rustc --version
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v3.2.0
|
|
||||||
- uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
.cargo/bin
|
|
||||||
.cargo/registry/index
|
|
||||||
.cargo/registry/cache
|
|
||||||
.cargo/git/db
|
|
||||||
target
|
|
||||||
key: lldap-bin-aarch64-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
lldap-bin-aarch64-
|
|
||||||
# - name: fetch musl-gcc
|
|
||||||
# run: |
|
|
||||||
# wget -c https://musl.cc/aarch64-linux-musl-cross.tgz
|
|
||||||
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
|
|
||||||
# echo "/opt/aarch64-linux-musl-cross:/opt/aarch64-linux-musl-cross/bin" >> $GITHUB_PATH
|
|
||||||
- name: add musl aarch64 target
|
|
||||||
run: rustup target add aarch64-unknown-linux-musl
|
|
||||||
- name: build lldap aarch4
|
|
||||||
run: cargo build --target=aarch64-unknown-linux-musl --release -p lldap -p migration-tool
|
|
||||||
- name: check path
|
|
||||||
run: ls -al target/aarch64-unknown-linux-musl/release/
|
|
||||||
- name: upload aarch64 lldap artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: aarch64-lldap-bin
|
|
||||||
path: target/aarch64-unknown-linux-musl/release/lldap
|
|
||||||
- name: upload aarch64 migration-tool artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: aarch64-migration-tool-bin
|
|
||||||
path: target/aarch64-unknown-linux-musl/release/migration-tool
|
|
||||||
|
|
||||||
build-amd64:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
# image: rust:1.65
|
|
||||||
image: nitnelave/rust-dev:latest
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
|
||||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
|
||||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: x86_64-linux-musl-gcc
|
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: x86_64-linux-musl-gcc
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||||
|
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.2.0
|
uses: actions/checkout@v3.4.0
|
||||||
- uses: actions/cache@v3
|
- uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
|
@ -223,82 +122,111 @@ jobs:
|
||||||
.cargo/registry/cache
|
.cargo/registry/cache
|
||||||
.cargo/git/db
|
.cargo/git/db
|
||||||
target
|
target
|
||||||
key: lldap-bin-amd64-${{ hashFiles('**/Cargo.lock') }}
|
key: lldap-bin-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
lldap-bin-amd64-
|
lldap-bin-${{ matrix.target }}-
|
||||||
- name: install musl
|
- name: Compile ${{ matrix.target }} lldap and tools
|
||||||
run: apt update && apt install -y musl-tools tar wget
|
run: cargo build --target=${{ matrix.target }} --release -p lldap -p migration-tool -p lldap_set_password
|
||||||
# - name: fetch musl-gcc
|
- name: Check path
|
||||||
# run: |
|
run: ls -al target/release
|
||||||
# wget -c https://musl.cc/x86_64-linux-musl-cross.tgz
|
- name: Upload ${{ matrix.target}} lldap artifacts
|
||||||
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
|
|
||||||
# echo "/opt/x86_64-linux-musl-cross:/opt/x86_64-linux-musl-cross/bin" >> $GITHUB_PATH
|
|
||||||
- name: add x86_64 target
|
|
||||||
run: rustup target add x86_64-unknown-linux-musl
|
|
||||||
- name: build x86_64 lldap
|
|
||||||
run: cargo build --target=x86_64-unknown-linux-musl --release -p lldap -p migration-tool
|
|
||||||
- name: check path
|
|
||||||
run: ls -al target/x86_64-unknown-linux-musl/release/
|
|
||||||
- name: upload amd64 lldap artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: amd64-lldap-bin
|
name: ${{ matrix.target}}-lldap-bin
|
||||||
path: target/x86_64-unknown-linux-musl/release/lldap
|
path: target/${{ matrix.target }}/release/lldap
|
||||||
- name: upload amd64 migration-tool artifacts
|
- name: Upload ${{ matrix.target }} migration tool artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: amd64-migration-tool-bin
|
name: ${{ matrix.target }}-migration-tool-bin
|
||||||
path: target/x86_64-unknown-linux-musl/release/migration-tool
|
path: target/${{ matrix.target }}/release/migration-tool
|
||||||
|
- name: Upload ${{ matrix.target }} password tool artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.target }}-lldap_set_password-bin
|
||||||
|
path: target/${{ matrix.target }}/release/lldap_set_password
|
||||||
|
|
||||||
|
lldap-database-integration-test:
|
||||||
|
needs: [build-ui,build-bin]
|
||||||
|
name: LLDAP test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
mariadb:
|
||||||
|
image: mariadb:latest
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
env:
|
||||||
|
MYSQL_USER: lldapuser
|
||||||
|
MYSQL_PASSWORD: lldappass
|
||||||
|
MYSQL_DATABASE: lldap
|
||||||
|
MYSQL_ROOT_PASSWORD: rootpass
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
image: postgres:latest
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: lldapuser
|
||||||
|
POSTGRES_PASSWORD: lldappass
|
||||||
|
POSTGRES_DB: lldap
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: x86_64-unknown-linux-musl-lldap-bin
|
||||||
|
path: bin/
|
||||||
|
- name: Where is the bin?
|
||||||
|
run: ls -alR bin
|
||||||
|
- name: Set executables to LLDAP
|
||||||
|
run: chmod +x bin/lldap
|
||||||
|
|
||||||
|
- name: Run lldap with postgres DB and healthcheck
|
||||||
|
run: |
|
||||||
|
bin/lldap run &
|
||||||
|
sleep 10s
|
||||||
|
bin/lldap healthcheck
|
||||||
|
env:
|
||||||
|
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
|
||||||
|
LLDAP_ldap_port: 3890
|
||||||
|
LLDAP_http_port: 17170
|
||||||
|
|
||||||
|
|
||||||
|
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
|
||||||
|
run: |
|
||||||
|
bin/lldap run &
|
||||||
|
sleep 10s
|
||||||
|
bin/lldap healthcheck
|
||||||
|
env:
|
||||||
|
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
|
||||||
|
LLDAP_ldap_port: 3891
|
||||||
|
LLDAP_http_port: 17171
|
||||||
|
|
||||||
|
|
||||||
|
- name: Run lldap with sqlite DB and healthcheck
|
||||||
|
run: |
|
||||||
|
bin/lldap run &
|
||||||
|
sleep 10s
|
||||||
|
bin/lldap healthcheck
|
||||||
|
env:
|
||||||
|
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||||
|
LLDAP_ldap_port: 3892
|
||||||
|
LLDAP_http_port: 17172
|
||||||
|
|
||||||
|
|
||||||
build-docker-image:
|
build-docker-image:
|
||||||
needs: [build-ui,build-armhf,build-aarch64,build-amd64]
|
needs: [build-ui, build-bin]
|
||||||
name: Build Docker image
|
name: Build Docker image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: install rsync
|
- name: Checkout repository
|
||||||
run: sudo apt update && sudo apt install -y rsync
|
uses: actions/checkout@v3.4.0
|
||||||
- name: fetch repo
|
- name: Download all artifacts
|
||||||
uses: actions/checkout@v3.2.0
|
|
||||||
|
|
||||||
- name: Download armhf lldap artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: armhf-lldap-bin
|
path: bin
|
||||||
path: bin/armhf-bin
|
|
||||||
- name: Download armhf migration-tool artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: armhf-migration-tool-bin
|
|
||||||
path: bin/armhf-bin
|
|
||||||
|
|
||||||
- name: Download aarch64 lldap artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: aarch64-lldap-bin
|
|
||||||
path: bin/aarch64-bin
|
|
||||||
- name: Download aarch64 migration-tool artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: aarch64-migration-tool-bin
|
|
||||||
path: bin/aarch64-bin
|
|
||||||
|
|
||||||
- name: Download amd64 lldap artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: amd64-lldap-bin
|
|
||||||
path: bin/amd64-bin
|
|
||||||
- name: Download amd64 migration-tool artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: amd64-migration-tool-bin
|
|
||||||
path: bin/amd64-bin
|
|
||||||
|
|
||||||
- name: check bin path
|
|
||||||
run: ls -al bin/
|
|
||||||
|
|
||||||
- name: Download llap ui artifacts
|
- name: Download llap ui artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
|
@ -306,7 +234,7 @@ jobs:
|
||||||
name: ui
|
name: ui
|
||||||
path: web
|
path: web
|
||||||
|
|
||||||
- name: setup qemu
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
- uses: docker/setup-buildx-action@v2
|
- uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
@ -325,13 +253,6 @@ jobs:
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=sha
|
type=sha
|
||||||
- name: Cache Docker layers
|
|
||||||
uses: actions/cache@v2
|
|
||||||
with:
|
|
||||||
path: /tmp/.buildx-cache
|
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-buildx-
|
|
||||||
|
|
||||||
- name: parse tag
|
- name: parse tag
|
||||||
uses: gacts/github-slug@v1
|
uses: gacts/github-slug@v1
|
||||||
|
@ -344,39 +265,39 @@ jobs:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
######################
|
########################################
|
||||||
#### latest build ####
|
#### docker image :latest tag build ####
|
||||||
######################
|
########################################
|
||||||
- name: Build and push latest alpine
|
- name: Build and push latest alpine
|
||||||
if: github.event_name != 'release'
|
if: github.event_name != 'release'
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
file: ./.github/workflows/Dockerfile.ci.alpine
|
file: ./.github/workflows/Dockerfile.ci.alpine
|
||||||
tags: nitnelave/lldap:latest, nitnelave/lldap:latest-alpine
|
tags: nitnelave/lldap:latest, nitnelave/lldap:latest-alpine
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=gha,mode=max
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push latest debian
|
- name: Build and push latest debian
|
||||||
if: github.event_name != 'release'
|
if: github.event_name != 'release'
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
file: ./.github/workflows/Dockerfile.ci.debian
|
file: ./.github/workflows/Dockerfile.ci.debian
|
||||||
tags: nitnelave/lldap:latest-debian
|
tags: nitnelave/lldap:latest-debian
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=gha,mode=max
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
#######################
|
########################################
|
||||||
#### release build ####
|
#### docker image :semver tag build ####
|
||||||
#######################
|
########################################
|
||||||
- name: Build and push release alpine
|
- name: Build and push release alpine
|
||||||
if: github.event_name == 'release'
|
if: github.event_name == 'release'
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
@ -384,12 +305,12 @@ jobs:
|
||||||
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
|
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
|
||||||
file: ./.github/workflows/Dockerfile.ci.alpine
|
file: ./.github/workflows/Dockerfile.ci.alpine
|
||||||
tags: nitnelave/lldap:stable, nitnelave/lldap:stable-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine.${{ steps.slug.outputs.version-minor }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-alpine
|
tags: nitnelave/lldap:stable, nitnelave/lldap:stable-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine.${{ steps.slug.outputs.version-minor }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-alpine
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=gha,mode=max
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Build and push release debian
|
- name: Build and push release debian
|
||||||
if: github.event_name == 'release'
|
if: github.event_name == 'release'
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
@ -397,11 +318,8 @@ jobs:
|
||||||
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
|
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
|
||||||
file: ./.github/workflows/Dockerfile.ci.debian
|
file: ./.github/workflows/Dockerfile.ci.debian
|
||||||
tags: nitnelave/lldap:stable-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-debian
|
tags: nitnelave/lldap:stable-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-debian
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=gha,mode=max
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Move cache
|
|
||||||
run: rsync -r /tmp/.buildx-cache-new /tmp/.buildx-cache --delete
|
|
||||||
|
|
||||||
- name: Update repo description
|
- name: Update repo description
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
|
@ -411,75 +329,86 @@ jobs:
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
repository: nitnelave/lldap
|
repository: nitnelave/lldap
|
||||||
|
|
||||||
|
###############################################################
|
||||||
|
### Download artifacts, clean up ui, upload to release page ###
|
||||||
|
###############################################################
|
||||||
create-release-artifacts:
|
create-release-artifacts:
|
||||||
needs: [build-ui,build-armhf,build-aarch64,build-amd64]
|
needs: [build-ui, build-bin]
|
||||||
name: Create release artifacts
|
name: Create release artifacts
|
||||||
if: github.event_name == 'release'
|
if: github.event_name == 'release'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
- name: Download armhf lldap artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: armhf-lldap-bin
|
path: bin/
|
||||||
path: bin/armhf-bin
|
- name: Check file
|
||||||
- name: Download armhf migration-tool artifacts
|
run: ls -alR bin/
|
||||||
uses: actions/download-artifact@v3
|
- name: Fixing Filename
|
||||||
with:
|
run: |
|
||||||
name: armhf-migration-tool-bin
|
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap bin/aarch64-lldap
|
||||||
path: bin/armhf-bin
|
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap bin/amd64-lldap
|
||||||
- name: Fix binary name armhf
|
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap bin/armhf-lldap
|
||||||
run: mv bin/armhf-bin/lldap bin/armhf-bin/lldap-armhf && mv bin/armhf-bin/migration-tool bin/armhf-bin/migration-tool-armhf
|
mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool bin/aarch64-migration-tool
|
||||||
|
mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool bin/amd64-migration-tool
|
||||||
- name: Download aarch64 lldap artifacts
|
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool bin/armhf-migration-tool
|
||||||
uses: actions/download-artifact@v3
|
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/aarch64-lldap_set_password
|
||||||
with:
|
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/amd64-lldap_set_password
|
||||||
name: aarch64-lldap-bin
|
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
|
||||||
path: bin/aarch64-bin
|
chmod +x bin/*-lldap
|
||||||
- name: Download aarch64 migration-tool artifacts
|
chmod +x bin/*-migration-tool
|
||||||
uses: actions/download-artifact@v3
|
chmod +x bin/*-lldap_set_password
|
||||||
with:
|
|
||||||
name: aarch64-migration-tool-bin
|
|
||||||
path: bin/aarch64-bin
|
|
||||||
- name: Fix binary name aarch64
|
|
||||||
run: mv bin/aarch64-bin/lldap bin/aarch64-bin/lldap-aarch64 && mv bin/aarch64-bin/migration-tool bin/aarch64-bin/migration-tool-aarch64
|
|
||||||
|
|
||||||
- name: Download amd64 lldap artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: amd64-lldap-bin
|
|
||||||
path: bin/amd64-bin
|
|
||||||
- name: Download amd64 migration-tool artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: amd64-migration-tool-bin
|
|
||||||
path: bin/amd64-bin
|
|
||||||
- name: Fix binary name amd64
|
|
||||||
run: mv bin/amd64-bin/lldap bin/amd64-bin/lldap-amd64 && mv bin/amd64-bin/migration-tool bin/amd64-bin/migration-tool-amd64
|
|
||||||
|
|
||||||
- name: Download llap ui artifacts
|
- name: Download llap ui artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ui
|
name: ui
|
||||||
path: web
|
path: web
|
||||||
- name: Web Cleanup
|
- name: UI (web) artifacts cleanup
|
||||||
run: mkdir app && mv web/index.html app/index.html && mv web/static app/static && mv web/pkg app/pkg
|
run: mkdir app && mv web/index.html app/index.html && mv web/static app/static && mv web/pkg app/pkg
|
||||||
- name: compress web
|
- name: Fetch web components
|
||||||
run: sudo apt update && sudo apt install -y zip && zip -r web.zip app/
|
run: |
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install wget
|
||||||
|
for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done
|
||||||
|
for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done
|
||||||
|
chmod a+r -R .
|
||||||
|
|
||||||
|
- name: Setup LLDAP dir for packing
|
||||||
|
run: |
|
||||||
|
mkdir aarch64-lldap
|
||||||
|
mkdir amd64-lldap
|
||||||
|
mkdir armhf-lldap
|
||||||
|
mv bin/aarch64-lldap aarch64-lldap/lldap
|
||||||
|
mv bin/amd64-lldap amd64-lldap/lldap
|
||||||
|
mv bin/armhf-lldap armhf-lldap/lldap
|
||||||
|
mv bin/aarch64-migration-tool aarch64-lldap/migration-tool
|
||||||
|
mv bin/amd64-migration-tool amd64-lldap/migration-tool
|
||||||
|
mv bin/armhf-migration-tool armhf-lldap/migration-tool
|
||||||
|
mv bin/aarch64-lldap_set_password aarch64-lldap/lldap_set_password
|
||||||
|
mv bin/amd64-lldap_set_password amd64-lldap/lldap_set_password
|
||||||
|
mv bin/armhf-lldap_set_password armhf-lldap/lldap_set_password
|
||||||
|
cp -r app aarch64-lldap/
|
||||||
|
cp -r app amd64-lldap/
|
||||||
|
cp -r app armhf-lldap/
|
||||||
|
ls -alR aarch64-lldap/
|
||||||
|
ls -alR amd64-lldap/
|
||||||
|
ls -alR armhf-lldap/
|
||||||
|
|
||||||
|
- name: Packing LLDAP and Web UI
|
||||||
|
run: |
|
||||||
|
tar -czvf aarch64-lldap.tar.gz aarch64-lldap/
|
||||||
|
tar -czvf amd64-lldap.tar.gz amd64-lldap/
|
||||||
|
tar -czvf armhf-lldap.tar.gz armhf-lldap/
|
||||||
|
|
||||||
|
|
||||||
- name: Upload artifacts release
|
- name: Upload compressed release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
id: create_release
|
id: create_release
|
||||||
with:
|
with:
|
||||||
allowUpdates: true
|
allowUpdates: true
|
||||||
artifacts: "bin/armhf-bin/lldap-armhf,
|
artifacts: aarch64-lldap.tar.gz,
|
||||||
bin/aarch64-bin/lldap-aarch64,
|
amd64-lldap.tar.gz,
|
||||||
bin/amd64-bin/lldap-amd64,
|
armhf-lldap.tar.gz
|
||||||
bin/armhf-bin/migration-tool-armhf,
|
|
||||||
bin/aarch64-bin/migration-tool-aarch64,
|
|
||||||
bin/amd64-bin/migration-tool-amd64,
|
|
||||||
web.zip"
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
8
.github/workflows/rust.yml
vendored
8
.github/workflows/rust.yml
vendored
|
@ -34,7 +34,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v3.2.0
|
uses: actions/checkout@v3.4.0
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose --workspace
|
run: cargo build --verbose --workspace
|
||||||
|
@ -53,7 +53,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v3.2.0
|
uses: actions/checkout@v3.4.0
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
@ -70,7 +70,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v3.2.0
|
uses: actions/checkout@v3.4.0
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
@ -87,7 +87,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v3.2.0
|
uses: actions/checkout@v3.4.0
|
||||||
|
|
||||||
- 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
|
||||||
|
|
2047
Cargo.lock
generated
2047
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
|
@ -3,16 +3,21 @@ members = [
|
||||||
"server",
|
"server",
|
||||||
"auth",
|
"auth",
|
||||||
"app",
|
"app",
|
||||||
"migration-tool"
|
"migration-tool",
|
||||||
|
"set-password",
|
||||||
]
|
]
|
||||||
|
|
||||||
default-members = ["server"]
|
default-members = ["server"]
|
||||||
|
|
||||||
# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged.
|
[profile.release]
|
||||||
[patch.crates-io.ldap3_proto]
|
lto = true
|
||||||
git = 'https://github.com/nitnelave/ldap3_server/'
|
|
||||||
rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc'
|
[profile.release.package.lldap_app]
|
||||||
|
opt-level = 's'
|
||||||
|
|
||||||
[patch.crates-io.opaque-ke]
|
[patch.crates-io.opaque-ke]
|
||||||
git = 'https://github.com/nitnelave/opaque-ke/'
|
git = 'https://github.com/nitnelave/opaque-ke/'
|
||||||
branch = 'zeroize_1.5'
|
branch = 'zeroize_1.5'
|
||||||
|
|
||||||
|
[patch.crates-io.lber]
|
||||||
|
git = 'https://github.com/inejge/ldap3/'
|
||||||
|
|
16
Dockerfile
16
Dockerfile
|
@ -1,5 +1,5 @@
|
||||||
# Build image
|
# Build image
|
||||||
FROM rust:alpine3.14 AS chef
|
FROM rust:alpine3.16 AS chef
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
# Add user
|
# Add user
|
||||||
|
@ -11,7 +11,7 @@ RUN set -x \
|
||||||
--uid 10001 \
|
--uid 10001 \
|
||||||
app \
|
app \
|
||||||
# Install required packages
|
# Install required packages
|
||||||
&& apk add npm openssl-dev musl-dev make perl curl
|
&& apk add openssl-dev musl-dev make perl curl gzip
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
@ -19,7 +19,6 @@ WORKDIR /app
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
# Install build tools
|
# Install build tools
|
||||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||||
&& npm install rollup \
|
|
||||||
&& rustup target add wasm32-unknown-unknown
|
&& rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
# Prepare the dependency list.
|
# Prepare the dependency list.
|
||||||
|
@ -32,16 +31,17 @@ FROM chef AS builder
|
||||||
COPY --from=planner /tmp/recipe.json recipe.json
|
COPY --from=planner /tmp/recipe.json recipe.json
|
||||||
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||||
&& cargo chef cook --release -p lldap \
|
&& cargo chef cook --release -p lldap \
|
||||||
&& cargo chef cook --release -p migration-tool
|
&& cargo chef cook --release -p migration-tool \
|
||||||
|
&& cargo chef cook --release -p lldap_set_password
|
||||||
|
|
||||||
# Copy the source and build the app and server.
|
# Copy the source and build the app and server.
|
||||||
COPY --chown=app:app . .
|
COPY --chown=app:app . .
|
||||||
RUN cargo build --release -p lldap -p migration-tool \
|
RUN cargo build --release -p lldap -p migration-tool -p lldap_set_password \
|
||||||
# Build the frontend.
|
# Build the frontend.
|
||||||
&& ./app/build.sh
|
&& ./app/build.sh
|
||||||
|
|
||||||
# Final image
|
# Final image
|
||||||
FROM alpine:3.14
|
FROM alpine:3.16
|
||||||
|
|
||||||
ENV GOSU_VERSION 1.14
|
ENV GOSU_VERSION 1.14
|
||||||
# Fetch gosu from git
|
# Fetch gosu from git
|
||||||
|
@ -78,7 +78,7 @@ WORKDIR /app
|
||||||
COPY --from=builder /app/app/index_local.html app/index.html
|
COPY --from=builder /app/app/index_local.html app/index.html
|
||||||
COPY --from=builder /app/app/static app/static
|
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/migration-tool ./
|
COPY --from=builder /app/target/release/lldap /app/target/release/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 ./
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
|
@ -94,4 +94,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
||||||
|
|
||||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
HEALTHCHECK CMD ["/app/lldap", "run", "--config-file", "/data/lldap_config.toml"]
|
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
|
93
README.md
93
README.md
|
@ -28,20 +28,20 @@
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
- [About](#About)
|
- [About](#about)
|
||||||
- [Installation](#Installation)
|
- [Installation](#installation)
|
||||||
- [With Docker](#With-Docker)
|
- [With Docker](#with-docker)
|
||||||
- [From source](#From-source)
|
- [From source](#from-source)
|
||||||
- [Cross-compilation](#Cross-compilation)
|
- [Cross-compilation](#cross-compilation)
|
||||||
- [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)
|
||||||
- [Sample client configurations](#Sample-client-configurations)
|
- [Sample client configurations](#sample-client-configurations)
|
||||||
- [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)
|
||||||
- [I can't log in!](#i-cant-log-in)
|
- [I can't log in!](#i-cant-log-in)
|
||||||
- [Contributions](#Contributions)
|
- [Contributions](#contributions)
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
|
@ -62,10 +62,11 @@ edit their own details or reset their password by email.
|
||||||
|
|
||||||
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
||||||
check out OpenLDAP. This server is a user management system that is:
|
check out OpenLDAP. This server is a user management system that is:
|
||||||
* simple to setup (no messing around with `slapd`),
|
|
||||||
* simple to manage (friendly web UI),
|
- simple to setup (no messing around with `slapd`),
|
||||||
* low resources,
|
- simple to manage (friendly web UI),
|
||||||
* opinionated with basic defaults so you don't have to understand the
|
- low resources,
|
||||||
|
- opinionated with basic defaults so you don't have to understand the
|
||||||
subtleties of LDAP.
|
subtleties of LDAP.
|
||||||
|
|
||||||
It mostly targets self-hosting servers, with open-source components like
|
It mostly targets self-hosting servers, with open-source components like
|
||||||
|
@ -98,14 +99,14 @@ contents are loaded into the respective configuration parameters. Note that
|
||||||
`_FILE` variables take precedence.
|
`_FILE` variables take precedence.
|
||||||
|
|
||||||
Example for docker compose:
|
Example for docker compose:
|
||||||
* You can use either the `:latest` tag image or `:stable` as used in this example.
|
|
||||||
* `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
|
|
||||||
* If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
|
|
||||||
* If no `TZ` is set, default `UTC` timezone will be used.
|
|
||||||
|
|
||||||
|
- You can use either the `:latest` tag image or `:stable` as used in this example.
|
||||||
|
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
|
||||||
|
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
|
||||||
|
- If no `TZ` is set, default `UTC` timezone will be used.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3'
|
version: "3"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
lldap_data:
|
lldap_data:
|
||||||
|
@ -135,13 +136,16 @@ services:
|
||||||
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
|
||||||
front-end.
|
front-end.
|
||||||
|
|
||||||
|
### With Kubernetes
|
||||||
|
|
||||||
|
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
To compile the project, you'll need:
|
To compile the project, you'll need:
|
||||||
|
|
||||||
* nodejs 16: [nodesource nodejs installation guide](https://github.com/nodesource/distributions)
|
- curl and gzip: `sudo apt install curl gzip`
|
||||||
* curl: `sudo apt install curl`
|
- Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
||||||
* Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
|
||||||
|
|
||||||
Then you can compile the server (and the migration tool if you want):
|
Then you can compile the server (and the migration tool if you want):
|
||||||
|
|
||||||
|
@ -155,8 +159,7 @@ just run `cargo run -- run` to run the server.
|
||||||
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:
|
cargo, you'll need:
|
||||||
|
|
||||||
* WASM-pack: `cargo install wasm-pack`
|
- WASM-pack: `cargo install wasm-pack`
|
||||||
* rollup.js: `npm install rollup`
|
|
||||||
|
|
||||||
Then you can build the frontend files with `./app/build.sh` (you'll need to run
|
Then you can build the frontend files with `./app/build.sh` (you'll need to run
|
||||||
this after every front-end change to update the WASM package served).
|
this after every front-end change to update the WASM package served).
|
||||||
|
@ -204,6 +207,7 @@ the config).
|
||||||
### General configuration guide
|
### General configuration guide
|
||||||
|
|
||||||
To configure the services that will talk to LLDAP, here are the values:
|
To configure the services that will talk to LLDAP, here are the values:
|
||||||
|
|
||||||
- The LDAP user DN is from the configuration. By default,
|
- The LDAP user DN is from the configuration. By default,
|
||||||
`cn=admin,ou=people,dc=example,dc=com`.
|
`cn=admin,ou=people,dc=example,dc=com`.
|
||||||
- The LDAP password is from the configuration (same as to log in to the web
|
- The LDAP password is from the configuration (same as to log in to the web
|
||||||
|
@ -226,12 +230,15 @@ administration access to many services.
|
||||||
Some specific clients have been tested to work and come with sample
|
Some specific clients have been tested to work and come with sample
|
||||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||||
folder for help with:
|
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)
|
||||||
- [Authelia](example_configs/authelia_config.yml)
|
- [Authelia](example_configs/authelia_config.yml)
|
||||||
|
- [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)
|
||||||
- [Dell iDRAC](example_configs/dell_idrac.md)
|
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||||
|
- [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)
|
||||||
- [Emby](example_configs/emby.md)
|
- [Emby](example_configs/emby.md)
|
||||||
|
@ -243,13 +250,16 @@ folder for help with:
|
||||||
- [KeyCloak](example_configs/keycloak.md)
|
- [KeyCloak](example_configs/keycloak.md)
|
||||||
- [Matrix](example_configs/matrix_synapse.yml)
|
- [Matrix](example_configs/matrix_synapse.yml)
|
||||||
- [Nextcloud](example_configs/nextcloud.md)
|
- [Nextcloud](example_configs/nextcloud.md)
|
||||||
|
- [Nexus](example_configs/nexus.md)
|
||||||
- [Organizr](example_configs/Organizr.md)
|
- [Organizr](example_configs/Organizr.md)
|
||||||
- [Portainer](example_configs/portainer.md)
|
- [Portainer](example_configs/portainer.md)
|
||||||
|
- [Rancher](example_configs/rancher.md)
|
||||||
- [Seafile](example_configs/seafile.md)
|
- [Seafile](example_configs/seafile.md)
|
||||||
- [Syncthing](example_configs/syncthing.md)
|
- [Syncthing](example_configs/syncthing.md)
|
||||||
- [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)
|
||||||
- [XBackBone](example_configs/xbackbone_config.php)
|
- [XBackBone](example_configs/xbackbone_config.php)
|
||||||
- [Zendto](example_configs/zendto.md)
|
- [Zendto](example_configs/zendto.md)
|
||||||
|
|
||||||
|
@ -257,26 +267,27 @@ folder for help with:
|
||||||
|
|
||||||
### vs OpenLDAP
|
### vs OpenLDAP
|
||||||
|
|
||||||
OpenLDAP is a monster of a service that implements all of LDAP and all of its
|
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
|
||||||
extensions, plus some of its own. That said, if you need all that flexibility,
|
all of LDAP and all of its extensions, plus some of its own. That said, if you
|
||||||
it might be what you need! Note that installation can be a bit painful
|
need all that flexibility, it might be what you need! Note that installation
|
||||||
(figuring out how to use `slapd`) and people have mixed experiences following
|
can be a bit painful (figuring out how to use `slapd`) and people have mixed
|
||||||
tutorials online. If you don't configure it properly, you might end up storing
|
experiences following tutorials online. If you don't configure it properly, you
|
||||||
passwords in clear, so a breach of your server would reveal all the stored
|
might end up storing passwords in clear, so a breach of your server would
|
||||||
passwords!
|
reveal all the stored passwords!
|
||||||
|
|
||||||
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
||||||
install one (not that many that look nice) and configure it.
|
install one (not that many look nice) and configure it.
|
||||||
|
|
||||||
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
||||||
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
|
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
|
||||||
|
However, it's not as flexible as OpenLDAP.
|
||||||
|
|
||||||
### vs FreeIPA
|
### vs FreeIPA
|
||||||
|
|
||||||
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
|
[FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
|
||||||
Samba, you name it, it has it. In addition to user management, it also does
|
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||||
security policies, single sign-on, certificate management, linux account
|
management, it also does security policies, single sign-on, certificate
|
||||||
management and so on.
|
management, linux account management and so on.
|
||||||
|
|
||||||
If you need all of that, go for it! Keep in mind that a more complex system is
|
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||||
more complex to maintain, though.
|
more complex to maintain, though.
|
||||||
|
@ -285,6 +296,18 @@ LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
|
||||||
configure (no messing around with DNS or security policies) and simpler to
|
configure (no messing around with DNS or security policies) and simpler to
|
||||||
use. It also comes conveniently packed in a docker container.
|
use. It also comes conveniently packed in a docker container.
|
||||||
|
|
||||||
|
### vs Kanidm
|
||||||
|
|
||||||
|
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
|
||||||
|
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
|
||||||
|
WebAuthn. It comes with a (read-only) LDAPS server.
|
||||||
|
|
||||||
|
It's fairly easy to install and does much more; but their LDAP server is
|
||||||
|
read-only, and by having more moving parts it is inherently more complex. If
|
||||||
|
you don't need to modify the users through LDAP and you're planning on
|
||||||
|
installing something like [KeyCloak](https://www.keycloak.org) to provide
|
||||||
|
modern identity protocols, check out Kanidm.
|
||||||
|
|
||||||
## I can't log in!
|
## I can't log in!
|
||||||
|
|
||||||
If you just set up the server, can get to the login page but the password you
|
If you just set up the server, can get to the login page but the password you
|
||||||
|
|
|
@ -8,22 +8,25 @@ include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
base64 = "0.13"
|
base64 = "0.13"
|
||||||
|
gloo-console = "0.2.3"
|
||||||
|
gloo-file = "0.2.3"
|
||||||
|
gloo-net = "*"
|
||||||
graphql_client = "0.10"
|
graphql_client = "0.10"
|
||||||
http = "0.2"
|
http = "0.2"
|
||||||
jwt = "0.13"
|
jwt = "0.13"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
url-escape = "0.1.1"
|
||||||
validator = "=0.14"
|
validator = "=0.14"
|
||||||
validator_derive = "*"
|
validator_derive = "*"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
yew = "0.18"
|
wasm-bindgen-futures = "*"
|
||||||
yewtil = "*"
|
yew = "0.19.3"
|
||||||
yew-router = "0.15"
|
yew-router = "0.16"
|
||||||
|
|
||||||
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
||||||
indexmap = "=1.6.2"
|
indexmap = "=1.6.2"
|
||||||
url-escape = "0.1.1"
|
|
||||||
|
|
||||||
[dependencies.web-sys]
|
[dependencies.web-sys]
|
||||||
version = "0.3"
|
version = "0.3"
|
||||||
|
@ -56,11 +59,11 @@ version = "0.24"
|
||||||
|
|
||||||
[dependencies.yew_form]
|
[dependencies.yew_form]
|
||||||
git = "https://github.com/jfbilodeau/yew_form"
|
git = "https://github.com/jfbilodeau/yew_form"
|
||||||
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
|
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||||
|
|
||||||
[dependencies.yew_form_derive]
|
[dependencies.yew_form_derive]
|
||||||
git = "https://github.com/jfbilodeau/yew_form"
|
git = "https://github.com/jfbilodeau/yew_form"
|
||||||
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
|
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
20
app/build.sh
20
app/build.sh
|
@ -6,22 +6,12 @@ then
|
||||||
>&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`'
|
>&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! which gzip > /dev/null 2>&1
|
||||||
wasm-pack build --target web
|
|
||||||
|
|
||||||
ROLLUP_BIN=$(which rollup 2>/dev/null)
|
|
||||||
if [ -f ../node_modules/rollup/dist/bin/rollup ]
|
|
||||||
then
|
then
|
||||||
ROLLUP_BIN=../node_modules/rollup/dist/bin/rollup
|
>&2 echo '`gzip` not found.'
|
||||||
elif [ -f node_modules/rollup/dist/bin/rollup ]
|
|
||||||
then
|
|
||||||
ROLLUP_BIN=node_modules/rollup/dist/bin/rollup
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$ROLLUP_BIN" ]
|
|
||||||
then
|
|
||||||
>&2 echo '`rollup` not found. Try running `npm install rollup`'
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap
|
wasm-pack build --target web --release
|
||||||
|
|
||||||
|
gzip -9 -f pkg/lldap_app_bg.wasm
|
||||||
|
|
|
@ -4,17 +4,21 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>LLDAP Administration</title>
|
<title>LLDAP Administration</title>
|
||||||
<script src="/pkg/bundle.js" defer></script>
|
<script src="/static/main.js" type="module" defer></script>
|
||||||
<link
|
<link
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
||||||
rel="preload stylesheet"
|
rel="preload stylesheet"
|
||||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
as="style" />
|
as="style" />
|
||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
|
||||||
|
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
||||||
|
@ -30,6 +34,11 @@
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/static/style.css" />
|
href="/static/style.css" />
|
||||||
|
<script>
|
||||||
|
function inDarkMode(){
|
||||||
|
return darkmode.inDarkMode;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
|
@ -4,15 +4,18 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>LLDAP Administration</title>
|
<title>LLDAP Administration</title>
|
||||||
<script src="/pkg/bundle.js" defer></script>
|
<script src="/static/main.js" type="module" defer></script>
|
||||||
<link
|
<link
|
||||||
href="/static/bootstrap.min.css"
|
href="/static/bootstrap-nightshade.min.css"
|
||||||
rel="preload stylesheet"
|
rel="preload stylesheet"
|
||||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||||
as="style" />
|
as="style" />
|
||||||
<script
|
<script
|
||||||
src="/static/bootstrap.bundle.min.js"
|
src="/static/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||||
|
<script
|
||||||
|
src="/static/darkmode.min.js"
|
||||||
|
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/static/bootstrap-icons.css"
|
href="/static/bootstrap-icons.css"
|
||||||
|
@ -28,6 +31,11 @@
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/static/style.css" />
|
href="/static/style.css" />
|
||||||
|
<script>
|
||||||
|
function inDarkMode(){
|
||||||
|
return darkmode.inDarkMode;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
|
@ -52,23 +52,25 @@ pub struct Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserListResponse(response) => {
|
Msg::UserListResponse(response) => {
|
||||||
self.user_list = Some(response?.users);
|
self.user_list = Some(response?.users);
|
||||||
self.common.cancel_task();
|
|
||||||
}
|
}
|
||||||
Msg::SubmitAddMember => return self.submit_add_member(),
|
Msg::SubmitAddMember => return self.submit_add_member(ctx),
|
||||||
Msg::AddMemberResponse(response) => {
|
Msg::AddMemberResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.common.cancel_task();
|
|
||||||
let user = self
|
let user = self
|
||||||
.selected_user
|
.selected_user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("Could not get selected user")
|
.expect("Could not get selected user")
|
||||||
.clone();
|
.clone();
|
||||||
// Remove the user from the dropdown.
|
// Remove the user from the dropdown.
|
||||||
self.common.on_user_added_to_group.emit(user);
|
ctx.props().on_user_added_to_group.emit(user);
|
||||||
}
|
}
|
||||||
Msg::SelectionChanged(option_props) => {
|
Msg::SelectionChanged(option_props) => {
|
||||||
let was_some = self.selected_user.is_some();
|
let was_some = self.selected_user.is_some();
|
||||||
|
@ -88,23 +90,25 @@ impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddGroupMemberComponent {
|
impl AddGroupMemberComponent {
|
||||||
fn get_user_list(&mut self) {
|
fn get_user_list(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<ListUserNames, _>(
|
self.common.call_graphql::<ListUserNames, _>(
|
||||||
|
ctx,
|
||||||
list_user_names::Variables { filters: None },
|
list_user_names::Variables { filters: None },
|
||||||
Msg::UserListResponse,
|
Msg::UserListResponse,
|
||||||
"Error trying to fetch user list",
|
"Error trying to fetch user list",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit_add_member(&mut self) -> Result<bool> {
|
fn submit_add_member(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
let user_id = match self.selected_user.clone() {
|
let user_id = match self.selected_user.clone() {
|
||||||
None => return Ok(false),
|
None => return Ok(false),
|
||||||
Some(user) => user.id,
|
Some(user) => user.id,
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<AddUserToGroup, _>(
|
self.common.call_graphql::<AddUserToGroup, _>(
|
||||||
|
ctx,
|
||||||
add_user_to_group::Variables {
|
add_user_to_group::Variables {
|
||||||
user: user_id,
|
user: user_id,
|
||||||
group: self.common.group_id,
|
group: ctx.props().group_id,
|
||||||
},
|
},
|
||||||
Msg::AddMemberResponse,
|
Msg::AddMemberResponse,
|
||||||
"Error trying to initiate adding the user to a group",
|
"Error trying to initiate adding the user to a group",
|
||||||
|
@ -112,8 +116,8 @@ impl AddGroupMemberComponent {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
|
fn get_selectable_user_list(&self, ctx: &Context<Self>, user_list: &[User]) -> Vec<User> {
|
||||||
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
|
let user_groups = ctx.props().users.iter().collect::<HashSet<_>>();
|
||||||
user_list
|
user_list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|u| !user_groups.contains(u))
|
.filter(|u| !user_groups.contains(u))
|
||||||
|
@ -126,41 +130,39 @@ impl Component for AddGroupMemberComponent {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut res = Self {
|
let mut res = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
user_list: None,
|
user_list: None,
|
||||||
selected_user: None,
|
selected_user: None,
|
||||||
};
|
};
|
||||||
res.get_user_list();
|
res.get_user_list(ctx);
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
if let Some(user_list) = &self.user_list {
|
if let Some(user_list) = &self.user_list {
|
||||||
let to_add_user_list = self.get_selectable_user_list(user_list);
|
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||||
#[allow(unused_braces)]
|
#[allow(unused_braces)]
|
||||||
let make_select_option = |user: User| {
|
let make_select_option = |user: User| {
|
||||||
html_nested! {
|
html_nested! {
|
||||||
<SelectOption value=user.id.clone() text=user.display_name.clone() key=user.id />
|
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-3">
|
||||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||||
{
|
{
|
||||||
to_add_user_list
|
to_add_user_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
@ -172,8 +174,8 @@ impl Component for AddGroupMemberComponent {
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
disabled={self.selected_user.is_none() || self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
onclick={link.callback(|_| Msg::SubmitAddMember)}>
|
||||||
<i class="bi-person-plus me-2"></i>
|
<i class="bi-person-plus me-2"></i>
|
||||||
{"Add to group"}
|
{"Add to group"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -64,16 +64,18 @@ pub struct Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::GroupListResponse(response) => {
|
Msg::GroupListResponse(response) => {
|
||||||
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
||||||
self.common.cancel_task();
|
|
||||||
}
|
}
|
||||||
Msg::SubmitAddGroup => return self.submit_add_group(),
|
Msg::SubmitAddGroup => return self.submit_add_group(ctx),
|
||||||
Msg::AddGroupResponse(response) => {
|
Msg::AddGroupResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.common.cancel_task();
|
|
||||||
// Adding the user to the group succeeded, we're not in the process of adding a
|
// Adding the user to the group succeeded, we're not in the process of adding a
|
||||||
// group anymore.
|
// group anymore.
|
||||||
let group = self
|
let group = self
|
||||||
|
@ -82,7 +84,7 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||||
.expect("Could not get selected group")
|
.expect("Could not get selected group")
|
||||||
.clone();
|
.clone();
|
||||||
// Remove the group from the dropdown.
|
// Remove the group from the dropdown.
|
||||||
self.common.on_user_added_to_group.emit(group);
|
ctx.props().on_user_added_to_group.emit(group);
|
||||||
}
|
}
|
||||||
Msg::SelectionChanged(option_props) => {
|
Msg::SelectionChanged(option_props) => {
|
||||||
let was_some = self.selected_group.is_some();
|
let was_some = self.selected_group.is_some();
|
||||||
|
@ -102,22 +104,24 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddUserToGroupComponent {
|
impl AddUserToGroupComponent {
|
||||||
fn get_group_list(&mut self) {
|
fn get_group_list(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<GetGroupList, _>(
|
self.common.call_graphql::<GetGroupList, _>(
|
||||||
|
ctx,
|
||||||
get_group_list::Variables,
|
get_group_list::Variables,
|
||||||
Msg::GroupListResponse,
|
Msg::GroupListResponse,
|
||||||
"Error trying to fetch group list",
|
"Error trying to fetch group list",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit_add_group(&mut self) -> Result<bool> {
|
fn submit_add_group(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
let group_id = match &self.selected_group {
|
let group_id = match &self.selected_group {
|
||||||
None => return Ok(false),
|
None => return Ok(false),
|
||||||
Some(group) => group.id,
|
Some(group) => group.id,
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<AddUserToGroup, _>(
|
self.common.call_graphql::<AddUserToGroup, _>(
|
||||||
|
ctx,
|
||||||
add_user_to_group::Variables {
|
add_user_to_group::Variables {
|
||||||
user: self.common.username.clone(),
|
user: ctx.props().username.clone(),
|
||||||
group: group_id,
|
group: group_id,
|
||||||
},
|
},
|
||||||
Msg::AddGroupResponse,
|
Msg::AddGroupResponse,
|
||||||
|
@ -126,8 +130,8 @@ impl AddUserToGroupComponent {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
|
fn get_selectable_group_list(&self, props: &Props, group_list: &[Group]) -> Vec<Group> {
|
||||||
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
|
let user_groups = props.groups.iter().collect::<HashSet<_>>();
|
||||||
group_list
|
group_list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|g| !user_groups.contains(g))
|
.filter(|g| !user_groups.contains(g))
|
||||||
|
@ -139,41 +143,39 @@ impl AddUserToGroupComponent {
|
||||||
impl Component for AddUserToGroupComponent {
|
impl Component for AddUserToGroupComponent {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut res = Self {
|
let mut res = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
group_list: None,
|
group_list: None,
|
||||||
selected_group: None,
|
selected_group: None,
|
||||||
};
|
};
|
||||||
res.get_group_list();
|
res.get_group_list(ctx);
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
if let Some(group_list) = &self.group_list {
|
if let Some(group_list) = &self.group_list {
|
||||||
let to_add_group_list = self.get_selectable_group_list(group_list);
|
let to_add_group_list = self.get_selectable_group_list(ctx.props(), group_list);
|
||||||
#[allow(unused_braces)]
|
#[allow(unused_braces)]
|
||||||
let make_select_option = |group: Group| {
|
let make_select_option = |group: Group| {
|
||||||
html_nested! {
|
html_nested! {
|
||||||
<SelectOption value=group.id.to_string() text=group.display_name key=group.id />
|
<SelectOption value={group.id.to_string()} text={group.display_name} key={group.id} />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-3">
|
||||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||||
{
|
{
|
||||||
to_add_group_list
|
to_add_group_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
@ -185,8 +187,8 @@ impl Component for AddUserToGroupComponent {
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-3">
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
disabled={self.selected_group.is_none() || self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
onclick={link.callback(|_| Msg::SubmitAddGroup)}>
|
||||||
<i class="bi-person-plus me-2"></i>
|
<i class="bi-person-plus me-2"></i>
|
||||||
{"Add to group"}
|
{"Add to group"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -10,104 +10,143 @@ use crate::{
|
||||||
logout::LogoutButton,
|
logout::LogoutButton,
|
||||||
reset_password_step1::ResetPasswordStep1Form,
|
reset_password_step1::ResetPasswordStep1Form,
|
||||||
reset_password_step2::ResetPasswordStep2Form,
|
reset_password_step2::ResetPasswordStep2Form,
|
||||||
router::{AppRoute, Link, NavButton},
|
router::{AppRoute, Link, Redirect},
|
||||||
user_details::UserDetails,
|
user_details::UserDetails,
|
||||||
user_table::UserTable,
|
user_table::UserTable,
|
||||||
},
|
},
|
||||||
infra::cookies::get_cookie,
|
infra::{api::HostService, cookies::get_cookie},
|
||||||
};
|
|
||||||
use yew::prelude::*;
|
|
||||||
use yew::services::ConsoleService;
|
|
||||||
use yew_router::{
|
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
router::Router,
|
|
||||||
service::RouteService,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use gloo_console::error;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use yew::{
|
||||||
|
function_component,
|
||||||
|
html::Scope,
|
||||||
|
prelude::{html, Component, Html},
|
||||||
|
Context,
|
||||||
|
};
|
||||||
|
use yew_router::{
|
||||||
|
prelude::{History, Location},
|
||||||
|
scope_ext::RouterScopeExt,
|
||||||
|
BrowserRouter, Switch,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = darkmode)]
|
||||||
|
fn toggleDarkMode(doSave: bool);
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
fn inDarkMode() -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(DarkModeToggle)]
|
||||||
|
pub fn dark_mode_toggle() -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
|
||||||
|
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AppContainer)]
|
||||||
|
pub fn app_container() -> Html {
|
||||||
|
html! {
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
link: ComponentLink<Self>,
|
|
||||||
user_info: Option<(String, bool)>,
|
user_info: Option<(String, bool)>,
|
||||||
redirect_to: Option<AppRoute>,
|
redirect_to: Option<AppRoute>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
password_reset_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Login((String, bool)),
|
Login((String, bool)),
|
||||||
Logout,
|
Logout,
|
||||||
|
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for App {
|
impl Component for App {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut app = Self {
|
let app = Self {
|
||||||
link,
|
|
||||||
user_info: get_cookie("user_id")
|
user_info: get_cookie("user_id")
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
ConsoleService::error(&e.to_string());
|
error!(&e.to_string());
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
.and_then(|u| {
|
.and_then(|u| {
|
||||||
get_cookie("is_admin")
|
get_cookie("is_admin")
|
||||||
.map(|so| so.map(|s| (u, s == "true")))
|
.map(|so| so.map(|s| (u, s == "true")))
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
ConsoleService::error(&e.to_string());
|
error!(&e.to_string());
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
redirect_to: Self::get_redirect_route(),
|
redirect_to: Self::get_redirect_route(ctx),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
password_reset_enabled: None,
|
||||||
};
|
};
|
||||||
app.apply_initial_redirections();
|
ctx.link().send_future(async move {
|
||||||
|
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
|
||||||
|
});
|
||||||
|
app.apply_initial_redirections(ctx);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
let history = ctx.link().history().unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Login((user_name, is_admin)) => {
|
Msg::Login((user_name, is_admin)) => {
|
||||||
self.user_info = Some((user_name.clone(), is_admin));
|
self.user_info = Some((user_name.clone(), is_admin));
|
||||||
self.route_dispatcher
|
history.push(self.redirect_to.take().unwrap_or_else(|| {
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(
|
|
||||||
self.redirect_to.take().unwrap_or_else(|| {
|
|
||||||
if is_admin {
|
if is_admin {
|
||||||
AppRoute::ListUsers
|
AppRoute::ListUsers
|
||||||
} else {
|
} else {
|
||||||
AppRoute::UserDetails(user_name.clone())
|
AppRoute::UserDetails {
|
||||||
|
user_id: user_name.clone(),
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
)));
|
}));
|
||||||
}
|
}
|
||||||
Msg::Logout => {
|
Msg::Logout => {
|
||||||
self.user_info = None;
|
self.user_info = None;
|
||||||
self.redirect_to = None;
|
self.redirect_to = None;
|
||||||
|
history.push(AppRoute::Login);
|
||||||
}
|
}
|
||||||
|
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
|
||||||
|
self.password_reset_enabled = Some(enabled);
|
||||||
|
}
|
||||||
|
Msg::PasswordResetProbeFinished(Err(err)) => {
|
||||||
|
self.password_reset_enabled = Some(false);
|
||||||
|
error!(&format!(
|
||||||
|
"Could not probe for password reset support: {err:#}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if self.user_info.is_none() {
|
|
||||||
self.route_dispatcher
|
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let link = ctx.link().clone();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
let link = self.link.clone();
|
|
||||||
let is_admin = self.is_admin();
|
let is_admin = self.is_admin();
|
||||||
|
let password_reset_enabled = self.password_reset_enabled;
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_banner()}
|
{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;">
|
||||||
<div class="py-3" style="max-width: 1000px">
|
<main class="py-3" style="max-width: 1000px">
|
||||||
<Router<AppRoute>
|
<Switch<AppRoute>
|
||||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{self.view_footer()}
|
{self.view_footer()}
|
||||||
</div>
|
</div>
|
||||||
|
@ -117,56 +156,56 @@ impl Component for App {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn get_redirect_route() -> Option<AppRoute> {
|
// Get the page to land on after logging in, defaulting to the index.
|
||||||
let route_service = RouteService::<()>::new();
|
fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
|
||||||
let current_route = route_service.get_path();
|
let route = ctx.link().history().unwrap().location().route::<AppRoute>();
|
||||||
if current_route.is_empty()
|
route.filter(|route| {
|
||||||
|| current_route == "/"
|
!matches!(
|
||||||
|| current_route.contains("login")
|
route,
|
||||||
|| current_route.contains("reset-password")
|
AppRoute::Index
|
||||||
{
|
| AppRoute::Login
|
||||||
None
|
| AppRoute::StartResetPassword
|
||||||
} else {
|
| AppRoute::FinishResetPassword { token: _ }
|
||||||
use yew_router::Switch;
|
)
|
||||||
AppRoute::from_route_part::<()>(current_route, None).0
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_initial_redirections(&mut self) {
|
fn apply_initial_redirections(&self, ctx: &Context<Self>) {
|
||||||
let route_service = RouteService::<()>::new();
|
let history = ctx.link().history().unwrap();
|
||||||
let current_route = route_service.get_path();
|
let route = history.location().route::<AppRoute>();
|
||||||
if current_route.contains("reset-password") {
|
let redirection = match (route, &self.user_info, &self.redirect_to) {
|
||||||
return;
|
(
|
||||||
}
|
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
|
||||||
match &self.user_info {
|
_,
|
||||||
None => {
|
_,
|
||||||
self.route_dispatcher
|
) if self.password_reset_enabled == Some(false) => Some(AppRoute::Login),
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
|
||||||
}
|
// User is logged in, a URL was given, don't redirect.
|
||||||
Some((user_name, is_admin)) => match &self.redirect_to {
|
(_, Some(_), Some(_)) => None,
|
||||||
Some(url) => {
|
(_, Some((user_name, is_admin)), None) => {
|
||||||
self.route_dispatcher
|
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if *is_admin {
|
if *is_admin {
|
||||||
self.route_dispatcher
|
Some(AppRoute::ListUsers)
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
|
|
||||||
} else {
|
} else {
|
||||||
self.route_dispatcher
|
Some(AppRoute::UserDetails {
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(
|
user_id: user_name.clone(),
|
||||||
AppRoute::UserDetails(user_name.clone()),
|
})
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
if let Some(redirect_to) = redirection {
|
||||||
|
history.push(redirect_to);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
|
fn dispatch_route(
|
||||||
|
switch: &AppRoute,
|
||||||
|
link: &Scope<Self>,
|
||||||
|
is_admin: bool,
|
||||||
|
password_reset_enabled: Option<bool>,
|
||||||
|
) -> Html {
|
||||||
match switch {
|
match switch {
|
||||||
AppRoute::Login => html! {
|
AppRoute::Login => html! {
|
||||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
<LoginForm on_logged_in={link.callback(Msg::Login)} password_reset_enabled={password_reset_enabled.unwrap_or(false)}/>
|
||||||
},
|
},
|
||||||
AppRoute::CreateUser => html! {
|
AppRoute::CreateUser => html! {
|
||||||
<CreateUserForm/>
|
<CreateUserForm/>
|
||||||
|
@ -174,10 +213,10 @@ impl App {
|
||||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||||
<div>
|
<div>
|
||||||
<UserTable />
|
<UserTable />
|
||||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>
|
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
||||||
<i class="bi-person-plus me-2"></i>
|
<i class="bi-person-plus me-2"></i>
|
||||||
{"Create a user"}
|
{"Create a user"}
|
||||||
</NavButton>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
},
|
},
|
||||||
AppRoute::CreateGroup => html! {
|
AppRoute::CreateGroup => html! {
|
||||||
|
@ -186,36 +225,45 @@ impl App {
|
||||||
AppRoute::ListGroups => html! {
|
AppRoute::ListGroups => html! {
|
||||||
<div>
|
<div>
|
||||||
<GroupTable />
|
<GroupTable />
|
||||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>
|
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
||||||
<i class="bi-plus-circle me-2"></i>
|
<i class="bi-plus-circle me-2"></i>
|
||||||
{"Create a group"}
|
{"Create a group"}
|
||||||
</NavButton>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
},
|
},
|
||||||
AppRoute::GroupDetails(group_id) => html! {
|
AppRoute::GroupDetails { group_id } => html! {
|
||||||
<GroupDetails group_id=group_id />
|
<GroupDetails group_id={*group_id} />
|
||||||
},
|
},
|
||||||
AppRoute::UserDetails(username) => html! {
|
AppRoute::UserDetails { user_id } => html! {
|
||||||
<UserDetails username=username is_admin=is_admin />
|
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||||
},
|
},
|
||||||
AppRoute::ChangePassword(username) => html! {
|
AppRoute::ChangePassword { user_id } => html! {
|
||||||
<ChangePasswordForm username=username is_admin=is_admin />
|
<ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
|
||||||
},
|
},
|
||||||
AppRoute::StartResetPassword => html! {
|
AppRoute::StartResetPassword => match password_reset_enabled {
|
||||||
<ResetPasswordStep1Form />
|
Some(true) => html! { <ResetPasswordStep1Form /> },
|
||||||
|
Some(false) => {
|
||||||
|
html! { <Redirect to={AppRoute::Login}/> }
|
||||||
|
}
|
||||||
|
|
||||||
|
None => html! {},
|
||||||
},
|
},
|
||||||
AppRoute::FinishResetPassword(token) => html! {
|
AppRoute::FinishResetPassword { token } => match password_reset_enabled {
|
||||||
<ResetPasswordStep2Form token=token />
|
Some(true) => html! { <ResetPasswordStep2Form token={token.clone()} /> },
|
||||||
|
Some(false) => {
|
||||||
|
html! { <Redirect to={AppRoute::Login}/> }
|
||||||
|
}
|
||||||
|
None => html! {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_banner(&self) -> Html {
|
fn view_banner(&self, ctx: &Context<Self>) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<header class="p-2 mb-3 border-bottom">
|
<header class="p-2 mb-3 border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
<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-dark text-decoration-none">
|
<a href="/" class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
||||||
<h2>{"LLDAP"}</h2>
|
<h2>{"LLDAP"}</h2>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@ -224,16 +272,16 @@ impl App {
|
||||||
<>
|
<>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
classes="nav-link px-2 link-dark h6"
|
classes="nav-link px-2 h6"
|
||||||
route=AppRoute::ListUsers>
|
to={AppRoute::ListUsers}>
|
||||||
<i class="bi-people me-2"></i>
|
<i class="bi-people me-2"></i>
|
||||||
{"Users"}
|
{"Users"}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
classes="nav-link px-2 link-dark h6"
|
classes="nav-link px-2 h6"
|
||||||
route=AppRoute::ListGroups>
|
to={AppRoute::ListGroups}>
|
||||||
<i class="bi-collection me-2"></i>
|
<i class="bi-collection me-2"></i>
|
||||||
{"Groups"}
|
{"Groups"}
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -242,7 +290,7 @@ impl App {
|
||||||
} } else { html!{} } }
|
} } else { html!{} } }
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="dropdown text-end">
|
/*<div class="dropdown text-end">
|
||||||
<a href="#"
|
<a href="#"
|
||||||
class="d-block link-dark text-decoration-none dropdown-toggle"
|
class="d-block link-dark text-decoration-none dropdown-toggle"
|
||||||
id="dropdownUser"
|
id="dropdownUser"
|
||||||
|
@ -283,16 +331,64 @@ impl App {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
} } else { html!{} } }
|
} } else { html!{} } }
|
||||||
</div>
|
</div>*/
|
||||||
|
{ self.view_user_menu(ctx) }
|
||||||
|
<DarkModeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 text-muted fixed-bottom bg-light py-2">
|
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||||
<div>
|
<div>
|
||||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,34 +1,27 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::router::{AppRoute, Link},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use gloo_console::error;
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::{prelude::*, services::ConsoleService};
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq)]
|
#[derive(PartialEq, Eq, Default)]
|
||||||
enum OpaqueData {
|
enum OpaqueData {
|
||||||
|
#[default]
|
||||||
None,
|
None,
|
||||||
Login(opaque::client::login::ClientLogin),
|
Login(opaque::client::login::ClientLogin),
|
||||||
Registration(opaque::client::registration::ClientRegistration),
|
Registration(opaque::client::registration::ClientRegistration),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OpaqueData {
|
|
||||||
fn default() -> Self {
|
|
||||||
OpaqueData::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpaqueData {
|
impl OpaqueData {
|
||||||
fn take(&mut self) -> Self {
|
fn take(&mut self) -> Self {
|
||||||
std::mem::take(self)
|
std::mem::take(self)
|
||||||
|
@ -61,7 +54,6 @@ pub struct ChangePasswordForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: Form<FormModel>,
|
form: Form<FormModel>,
|
||||||
opaque_data: OpaqueData,
|
opaque_data: OpaqueData,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||||
|
@ -80,15 +72,20 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use anyhow::Context;
|
||||||
match msg {
|
match msg {
|
||||||
Msg::FormUpdate => Ok(true),
|
Msg::FormUpdate => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
bail!("Check the form for errors");
|
bail!("Check the form for errors");
|
||||||
}
|
}
|
||||||
if self.common.is_admin {
|
if ctx.props().is_admin {
|
||||||
self.handle_msg(Msg::SubmitNewPassword)
|
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||||
} else {
|
} else {
|
||||||
let old_password = self.form.model().old_password;
|
let old_password = self.form.model().old_password;
|
||||||
if old_password.is_empty() {
|
if old_password.is_empty() {
|
||||||
|
@ -100,14 +97,14 @@ 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: self.common.username.clone(),
|
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(
|
||||||
HostService::login_start,
|
ctx,
|
||||||
req,
|
HostService::login_start(req),
|
||||||
Msg::AuthenticationStartResponse,
|
Msg::AuthenticationStartResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -119,17 +116,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||||
|e| {
|
|e| {
|
||||||
// Common error, we want to print a full error to the console but only a
|
// Common error, we want to print a full error to the console but only a
|
||||||
// simple one to the user.
|
// simple one to the user.
|
||||||
ConsoleService::error(&format!(
|
error!(&format!("Invalid username or password: {}", e));
|
||||||
"Invalid username or password: {}",
|
|
||||||
e
|
|
||||||
));
|
|
||||||
anyhow!("Invalid username or password")
|
anyhow!("Invalid username or password")
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
_ => panic!("Unexpected data in opaque_data field"),
|
_ => panic!("Unexpected data in opaque_data field"),
|
||||||
};
|
};
|
||||||
self.handle_msg(Msg::SubmitNewPassword)
|
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||||
}
|
}
|
||||||
Msg::SubmitNewPassword => {
|
Msg::SubmitNewPassword => {
|
||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
|
@ -138,15 +132,15 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
opaque::client::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.common.username.clone(),
|
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);
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_start,
|
ctx,
|
||||||
req,
|
HostService::register_start(req),
|
||||||
Msg::RegistrationStartResponse,
|
Msg::RegistrationStartResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::RegistrationStartResponse(res) => {
|
Msg::RegistrationStartResponse(res) => {
|
||||||
|
@ -166,22 +160,20 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||||
registration_upload: registration_finish.message,
|
registration_upload: registration_finish.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_finish,
|
ctx,
|
||||||
req,
|
HostService::register_finish(req),
|
||||||
Msg::RegistrationFinishResponse,
|
Msg::RegistrationFinishResponse,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
_ => panic!("Unexpected data in opaque_data field"),
|
_ => panic!("Unexpected data in opaque_data field"),
|
||||||
}?;
|
};
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
if response.is_ok() {
|
if response.is_ok() {
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::UserDetails {
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(
|
user_id: ctx.props().username.clone(),
|
||||||
AppRoute::UserDetails(self.common.username.clone()),
|
});
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
response?;
|
response?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
@ -198,25 +190,21 @@ impl Component for ChangePasswordForm {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
ChangePasswordForm {
|
ChangePasswordForm {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||||
opaque_data: OpaqueData::None,
|
opaque_data: OpaqueData::None,
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let is_admin = ctx.props().is_admin;
|
||||||
}
|
let link = ctx.link();
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
let is_admin = self.common.is_admin;
|
|
||||||
type Field = yew_form::Field<FormModel>;
|
type Field = yew_form::Field<FormModel>;
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
|
@ -244,13 +232,14 @@ impl Component for ChangePasswordForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="old_password"
|
field_name="old_password"
|
||||||
|
input_type="password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("old_password")}
|
{&self.form.field_message("old_password")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -266,14 +255,14 @@ impl Component for ChangePasswordForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="password"
|
field_name="password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("password")}
|
{&self.form.field_message("password")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -288,14 +277,14 @@ impl Component for ChangePasswordForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="confirm_password"
|
field_name="confirm_password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("confirm_password")}
|
{&self.form.field_message("confirm_password")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -305,17 +294,17 @@ impl Component for ChangePasswordForm {
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary col-auto col-form-label"
|
class="btn btn-primary col-auto col-form-label"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||||
<i class="bi-save me-2"></i>
|
<i class="bi-save me-2"></i>
|
||||||
{"Save changes"}
|
{"Save changes"}
|
||||||
</button>
|
</button>
|
||||||
<NavButton
|
<Link
|
||||||
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
||||||
route=AppRoute::UserDetails(self.common.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"}
|
||||||
</NavButton>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -3,15 +3,12 @@ use crate::{
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::ConsoleService;
|
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
|
@ -24,7 +21,6 @@ pub struct CreateGroup;
|
||||||
|
|
||||||
pub struct CreateGroupForm {
|
pub struct CreateGroupForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
form: yew_form::Form<CreateGroupModel>,
|
form: yew_form::Form<CreateGroupModel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +37,11 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
|
@ -53,6 +53,7 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||||
name: model.groupname,
|
name: model.groupname,
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateGroup, _>(
|
self.common.call_graphql::<CreateGroup, _>(
|
||||||
|
ctx,
|
||||||
req,
|
req,
|
||||||
Msg::CreateGroupResponse,
|
Msg::CreateGroupResponse,
|
||||||
"Error trying to create group",
|
"Error trying to create group",
|
||||||
|
@ -60,12 +61,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::CreateGroupResponse(response) => {
|
Msg::CreateGroupResponse(response) => {
|
||||||
ConsoleService::log(&format!(
|
log!(&format!(
|
||||||
"Created group '{}'",
|
"Created group '{}'",
|
||||||
&response?.create_group.display_name
|
&response?.create_group.display_name
|
||||||
));
|
));
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -80,23 +80,19 @@ impl Component for CreateGroupForm {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<CreateGroupModel>;
|
type Field = yew_form::Field<CreateGroupModel>;
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
@ -113,13 +109,13 @@ impl Component for CreateGroupForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="groupname"
|
field_name="groupname"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="groupname"
|
autocomplete="groupname"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("groupname")}
|
{&self.form.field_message("groupname")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -129,8 +125,8 @@ impl Component for CreateGroupForm {
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary col-auto col-form-label"
|
class="btn btn-primary col-auto col-form-label"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||||
<i class="bi-save me-2"></i>
|
<i class="bi-save me-2"></i>
|
||||||
{"Submit"}
|
{"Submit"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -5,17 +5,14 @@ use crate::{
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{opaque, registration};
|
use lldap_auth::{opaque, registration};
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::ConsoleService;
|
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
|
@ -28,7 +25,6 @@ pub struct CreateUser;
|
||||||
|
|
||||||
pub struct CreateUserForm {
|
pub struct CreateUserForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
form: yew_form::Form<CreateUserModel>,
|
form: yew_form::Form<CreateUserModel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,7 +69,11 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
|
@ -93,6 +93,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateUser, _>(
|
self.common.call_graphql::<CreateUser, _>(
|
||||||
|
ctx,
|
||||||
req,
|
req,
|
||||||
Msg::CreateUserResponse,
|
Msg::CreateUserResponse,
|
||||||
"Error trying to create user",
|
"Error trying to create user",
|
||||||
|
@ -102,7 +103,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||||
Msg::CreateUserResponse(r) => {
|
Msg::CreateUserResponse(r) => {
|
||||||
match r {
|
match r {
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
Ok(r) => ConsoleService::log(&format!(
|
Ok(r) => log!(&format!(
|
||||||
"Created user '{}' at '{}'",
|
"Created user '{}' at '{}'",
|
||||||
&r.create_user.id, &r.create_user.creation_date
|
&r.create_user.id, &r.create_user.creation_date
|
||||||
)),
|
)),
|
||||||
|
@ -122,12 +123,11 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||||
registration_start_request: message,
|
registration_start_request: message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common
|
||||||
.call_backend(HostService::register_start, req, move |r| {
|
.call_backend(ctx, HostService::register_start(req), move |r| {
|
||||||
Msg::RegistrationStartResponse((state, r))
|
Msg::RegistrationStartResponse((state, r))
|
||||||
})
|
});
|
||||||
.context("Error trying to create user")?;
|
|
||||||
} else {
|
} else {
|
||||||
self.update(Msg::SuccessfulCreation);
|
self.update(ctx, Msg::SuccessfulCreation);
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
@ -143,22 +143,19 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||||
server_data: response.server_data,
|
server_data: response.server_data,
|
||||||
registration_upload: registration_upload.message,
|
registration_upload: registration_upload.message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common.call_backend(
|
||||||
.call_backend(
|
ctx,
|
||||||
HostService::register_finish,
|
HostService::register_finish(req),
|
||||||
req,
|
|
||||||
Msg::RegistrationFinishResponse,
|
Msg::RegistrationFinishResponse,
|
||||||
)
|
);
|
||||||
.context("Error trying to register user")?;
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.handle_msg(Msg::SuccessfulCreation)
|
self.handle_msg(ctx, Msg::SuccessfulCreation)
|
||||||
}
|
}
|
||||||
Msg::SuccessfulCreation => {
|
Msg::SuccessfulCreation => {
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::ListUsers);
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListUsers)));
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -173,23 +170,19 @@ impl Component for CreateUserForm {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<CreateUserModel>;
|
type Field = yew_form::Field<CreateUserModel>;
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
@ -206,13 +199,13 @@ impl Component for CreateUserForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="username"
|
field_name="username"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("username")}
|
{&self.form.field_message("username")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -227,14 +220,14 @@ impl Component for CreateUserForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
input_type="email"
|
input_type="email"
|
||||||
field_name="email"
|
field_name="email"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("email")}
|
{&self.form.field_message("email")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -247,13 +240,13 @@ impl Component for CreateUserForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
autocomplete="name"
|
autocomplete="name"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
field_name="display_name"
|
field_name="display_name"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("display_name")}
|
{&self.form.field_message("display_name")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -266,13 +259,13 @@ impl Component for CreateUserForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
autocomplete="given-name"
|
autocomplete="given-name"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
field_name="first_name"
|
field_name="first_name"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("first_name")}
|
{&self.form.field_message("first_name")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -285,13 +278,13 @@ impl Component for CreateUserForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
autocomplete="family-name"
|
autocomplete="family-name"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
field_name="last_name"
|
field_name="last_name"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("last_name")}
|
{&self.form.field_message("last_name")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -304,14 +297,14 @@ impl Component for CreateUserForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
input_type="password"
|
input_type="password"
|
||||||
field_name="password"
|
field_name="password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("password")}
|
{&self.form.field_message("password")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -324,14 +317,14 @@ impl Component for CreateUserForm {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
input_type="password"
|
input_type="password"
|
||||||
field_name="confirm_password"
|
field_name="confirm_password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("confirm_password")}
|
{&self.form.field_message("confirm_password")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -340,9 +333,9 @@ impl Component for CreateUserForm {
|
||||||
<div class="form-group row justify-content-center">
|
<div class="form-group row justify-content-center">
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary col-auto col-form-label mt-4"
|
class="btn btn-primary col-auto col-form-label mt-4"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
type="submit"
|
type="submit"
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||||
<i class="bi-save me-2"></i>
|
<i class="bi-save me-2"></i>
|
||||||
{"Submit"}
|
{"Submit"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -39,16 +39,21 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ClickedDeleteGroup => {
|
Msg::ClickedDeleteGroup => {
|
||||||
self.modal.as_ref().expect("modal not initialized").show();
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
}
|
}
|
||||||
Msg::ConfirmDeleteGroup => {
|
Msg::ConfirmDeleteGroup => {
|
||||||
self.update(Msg::DismissModal);
|
self.update(ctx, Msg::DismissModal);
|
||||||
self.common.call_graphql::<DeleteGroupQuery, _>(
|
self.common.call_graphql::<DeleteGroupQuery, _>(
|
||||||
|
ctx,
|
||||||
delete_group_query::Variables {
|
delete_group_query::Variables {
|
||||||
group_id: self.common.group.id,
|
group_id: ctx.props().group.id,
|
||||||
},
|
},
|
||||||
Msg::DeleteGroupResponse,
|
Msg::DeleteGroupResponse,
|
||||||
"Error trying to delete group",
|
"Error trying to delete group",
|
||||||
|
@ -58,12 +63,8 @@ impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||||
self.modal.as_ref().expect("modal not initialized").hide();
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
}
|
}
|
||||||
Msg::DeleteGroupResponse(response) => {
|
Msg::DeleteGroupResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
response?;
|
response?;
|
||||||
self.common
|
ctx.props().on_group_deleted.emit(ctx.props().group.id);
|
||||||
.props
|
|
||||||
.on_group_deleted
|
|
||||||
.emit(self.common.group.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
@ -78,15 +79,15 @@ impl Component for DeleteGroup {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = DeleteGroupProps;
|
type Properties = DeleteGroupProps;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
modal: None,
|
modal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered(&mut self, first_render: bool) {
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
if first_render {
|
if first_render {
|
||||||
self.modal = Some(Modal::new(
|
self.modal = Some(Modal::new(
|
||||||
self.node_ref
|
self.node_ref
|
||||||
|
@ -96,43 +97,42 @@ impl Component for DeleteGroup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
onclick={link.callback(|_| Msg::ClickedDeleteGroup)}>
|
||||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||||
</button>
|
</button>
|
||||||
{self.show_modal()}
|
{self.show_modal(ctx)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeleteGroup {
|
impl DeleteGroup {
|
||||||
fn show_modal(&self) -> Html {
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
class="modal fade"
|
||||||
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
|
id={"deleteGroupModal".to_string() + &ctx.props().group.id.to_string()}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-labelledby="deleteGroupModalLabel"
|
aria-labelledby="deleteGroupModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
ref=self.node_ref.clone()>
|
ref={self.node_ref.clone()}>
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
@ -141,25 +141,25 @@ impl DeleteGroup {
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span>
|
<span>
|
||||||
{"Are you sure you want to delete group "}
|
{"Are you sure you want to delete group "}
|
||||||
<b>{&self.common.group.display_name}</b>{"?"}
|
<b>{&ctx.props().group.display_name}</b>{"?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
<i class="bi-x-circle me-2"></i>
|
<i class="bi-x-circle me-2"></i>
|
||||||
{"Cancel"}
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
onclick={link.callback(|_| Msg::ConfirmDeleteGroup)}
|
||||||
class="btn btn-danger">
|
class="btn btn-danger">
|
||||||
<i class="bi-check-circle me-2"></i>
|
<i class="bi-check-circle me-2"></i>
|
||||||
{"Yes, I'm sure"}
|
{"Yes, I'm sure"}
|
||||||
|
|
|
@ -36,16 +36,21 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<DeleteUser> for DeleteUser {
|
impl CommonComponent<DeleteUser> for DeleteUser {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ClickedDeleteUser => {
|
Msg::ClickedDeleteUser => {
|
||||||
self.modal.as_ref().expect("modal not initialized").show();
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
}
|
}
|
||||||
Msg::ConfirmDeleteUser => {
|
Msg::ConfirmDeleteUser => {
|
||||||
self.update(Msg::DismissModal);
|
self.update(ctx, Msg::DismissModal);
|
||||||
self.common.call_graphql::<DeleteUserQuery, _>(
|
self.common.call_graphql::<DeleteUserQuery, _>(
|
||||||
|
ctx,
|
||||||
delete_user_query::Variables {
|
delete_user_query::Variables {
|
||||||
user: self.common.username.clone(),
|
user: ctx.props().username.clone(),
|
||||||
},
|
},
|
||||||
Msg::DeleteUserResponse,
|
Msg::DeleteUserResponse,
|
||||||
"Error trying to delete user",
|
"Error trying to delete user",
|
||||||
|
@ -55,12 +60,10 @@ impl CommonComponent<DeleteUser> for DeleteUser {
|
||||||
self.modal.as_ref().expect("modal not initialized").hide();
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
}
|
}
|
||||||
Msg::DeleteUserResponse(response) => {
|
Msg::DeleteUserResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
response?;
|
response?;
|
||||||
self.common
|
ctx.props()
|
||||||
.props
|
|
||||||
.on_user_deleted
|
.on_user_deleted
|
||||||
.emit(self.common.username.clone());
|
.emit(ctx.props().username.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
@ -75,15 +78,15 @@ impl Component for DeleteUser {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = DeleteUserProps;
|
type Properties = DeleteUserProps;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
modal: None,
|
modal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered(&mut self, first_render: bool) {
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
if first_render {
|
if first_render {
|
||||||
self.modal = Some(Modal::new(
|
self.modal = Some(Modal::new(
|
||||||
self.node_ref
|
self.node_ref
|
||||||
|
@ -93,44 +96,43 @@ impl Component for DeleteUser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
onclick={link.callback(|_| Msg::ClickedDeleteUser)}>
|
||||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||||
</button>
|
</button>
|
||||||
{self.show_modal()}
|
{self.show_modal(ctx)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeleteUser {
|
impl DeleteUser {
|
||||||
fn show_modal(&self) -> Html {
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
class="modal fade"
|
||||||
id="deleteUserModal".to_string() + &self.common.username
|
id={"deleteUserModal".to_string() + &ctx.props().username}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
//role="dialog"
|
//role="dialog"
|
||||||
aria-labelledby="deleteUserModalLabel"
|
aria-labelledby="deleteUserModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
ref=self.node_ref.clone()>
|
ref={self.node_ref.clone()}>
|
||||||
<div class="modal-dialog" /*role="document"*/>
|
<div class="modal-dialog" /*role="document"*/>
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
@ -139,25 +141,25 @@ impl DeleteUser {
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span>
|
<span>
|
||||||
{"Are you sure you want to delete user "}
|
{"Are you sure you want to delete user "}
|
||||||
<b>{&self.common.username}</b>{"?"}
|
<b>{&ctx.props().username}</b>{"?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
<i class="bi-x-circle me-2"></i>
|
<i class="bi-x-circle me-2"></i>
|
||||||
{"Cancel"}
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
onclick={link.callback(|_| Msg::ConfirmDeleteUser)}
|
||||||
class="btn btn-danger">
|
class="btn btn-danger">
|
||||||
<i class="bi-check-circle me-2"></i>
|
<i class="bi-check-circle me-2"></i>
|
||||||
{"Yes, I'm sure"}
|
{"Yes, I'm sure"}
|
||||||
|
|
|
@ -46,10 +46,11 @@ pub struct Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupDetails {
|
impl GroupDetails {
|
||||||
fn get_group_details(&mut self) {
|
fn get_group_details(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<GetGroupDetails, _>(
|
self.common.call_graphql::<GetGroupDetails, _>(
|
||||||
|
ctx,
|
||||||
get_group_details::Variables {
|
get_group_details::Variables {
|
||||||
id: self.common.group_id,
|
id: ctx.props().group_id,
|
||||||
},
|
},
|
||||||
Msg::GroupDetailsResponse,
|
Msg::GroupDetailsResponse,
|
||||||
"Error trying to fetch group details",
|
"Error trying to fetch group details",
|
||||||
|
@ -107,24 +108,25 @@ impl GroupDetails {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_user_list(&self, g: &Group) -> Html {
|
fn view_user_list(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
let make_user_row = |user: &User| {
|
let make_user_row = |user: &User| {
|
||||||
let user_id = user.id.clone();
|
let user_id = user.id.clone();
|
||||||
let display_name = user.display_name.clone();
|
let display_name = user.display_name.clone();
|
||||||
html! {
|
html! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::UserDetails(user_id.clone())>
|
<Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
|
||||||
{user_id.clone()}
|
{user_id.clone()}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>{display_name}</td>
|
<td>{display_name}</td>
|
||||||
<td>
|
<td>
|
||||||
<RemoveUserFromGroupComponent
|
<RemoveUserFromGroupComponent
|
||||||
username=user_id
|
username={user_id}
|
||||||
group_id=g.id
|
group_id={g.id}
|
||||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
@ -159,7 +161,8 @@ impl GroupDetails {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_add_user_button(&self, g: &Group) -> Html {
|
fn view_add_user_button(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
let users: Vec<_> = g
|
let users: Vec<_> = g
|
||||||
.users
|
.users
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -170,16 +173,16 @@ impl GroupDetails {
|
||||||
.collect();
|
.collect();
|
||||||
html! {
|
html! {
|
||||||
<AddGroupMemberComponent
|
<AddGroupMemberComponent
|
||||||
group_id=g.id
|
group_id={g.id}
|
||||||
users=users
|
users={users}
|
||||||
on_error=self.common.callback(Msg::OnError)
|
on_error={link.callback(Msg::OnError)}
|
||||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::GroupDetailsResponse(response) => match response {
|
Msg::GroupDetailsResponse(response) => match response {
|
||||||
Ok(group) => self.group = Some(group.group),
|
Ok(group) => self.group = Some(group.group),
|
||||||
|
@ -215,24 +218,20 @@ impl Component for GroupDetails {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
group: None,
|
group: None,
|
||||||
};
|
};
|
||||||
table.get_group_details();
|
table.get_group_details(ctx);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
match (&self.group, &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>},
|
||||||
|
@ -240,8 +239,8 @@ impl Component for GroupDetails {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_details(u)}
|
{self.view_details(u)}
|
||||||
{self.view_user_list(u)}
|
{self.view_user_list(ctx, u)}
|
||||||
{self.view_add_user_button(u)}
|
{self.view_add_user_button(ctx, u)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<GroupTable> for GroupTable {
|
impl CommonComponent<GroupTable> for GroupTable {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ListGroupsResponse(groups) => {
|
Msg::ListGroupsResponse(groups) => {
|
||||||
self.groups = Some(groups?.groups.into_iter().collect());
|
self.groups = Some(groups?.groups.into_iter().collect());
|
||||||
|
@ -58,12 +58,13 @@ impl Component for GroupTable {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = GroupTable {
|
let mut table = GroupTable {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
groups: None,
|
groups: None,
|
||||||
};
|
};
|
||||||
table.common.call_graphql::<GetGroupList, _>(
|
table.common.call_graphql::<GetGroupList, _>(
|
||||||
|
ctx,
|
||||||
get_group_list::Variables {},
|
get_group_list::Variables {},
|
||||||
Msg::ListGroupsResponse,
|
Msg::ListGroupsResponse,
|
||||||
"Error trying to fetch groups",
|
"Error trying to fetch groups",
|
||||||
|
@ -71,18 +72,14 @@ impl Component for GroupTable {
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_groups()}
|
{self.view_groups(ctx)}
|
||||||
{self.view_errors()}
|
{self.view_errors()}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -90,7 +87,7 @@ impl Component for GroupTable {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupTable {
|
impl GroupTable {
|
||||||
fn view_groups(&self) -> Html {
|
fn view_groups(&self, ctx: &Context<Self>) -> Html {
|
||||||
let make_table = |groups: &Vec<Group>| {
|
let make_table = |groups: &Vec<Group>| {
|
||||||
html! {
|
html! {
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
|
@ -103,7 +100,7 @@ impl GroupTable {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{groups.iter().map(|u| self.view_group(u)).collect::<Vec<_>>()}
|
{groups.iter().map(|u| self.view_group(ctx, u)).collect::<Vec<_>>()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -115,11 +112,12 @@ impl GroupTable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_group(&self, group: &Group) -> Html {
|
fn view_group(&self, ctx: &Context<Self>, group: &Group) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<tr key=group.id>
|
<tr key={group.id}>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::GroupDetails(group.id)>
|
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||||
{&group.display_name}
|
{&group.display_name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
@ -128,9 +126,9 @@ impl GroupTable {
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<DeleteGroup
|
<DeleteGroup
|
||||||
group=group.clone()
|
group={group.clone()}
|
||||||
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
on_group_deleted={link.callback(Msg::OnGroupDeleted)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::router::{AppRoute, Link},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use gloo_console::error;
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::{prelude::*, services::ConsoleService};
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
|
@ -30,6 +31,7 @@ pub struct FormModel {
|
||||||
#[derive(Clone, PartialEq, Properties)]
|
#[derive(Clone, PartialEq, Properties)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub on_logged_in: Callback<(String, bool)>,
|
pub on_logged_in: Callback<(String, bool)>,
|
||||||
|
pub password_reset_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
|
@ -46,7 +48,12 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<LoginForm> for LoginForm {
|
impl CommonComponent<LoginForm> for LoginForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use anyhow::Context;
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
|
@ -63,9 +70,9 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||||
login_start_request: message,
|
login_start_request: message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common
|
||||||
.call_backend(HostService::login_start, req, move |r| {
|
.call_backend(ctx, HostService::login_start(req), move |r| {
|
||||||
Msg::AuthenticationStartResponse((state, r))
|
Msg::AuthenticationStartResponse((state, r))
|
||||||
})?;
|
});
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
Msg::AuthenticationStartResponse((login_start, res)) => {
|
||||||
|
@ -76,9 +83,8 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Common error, we want to print a full error to the console but only a
|
// Common error, we want to print a full error to the console but only a
|
||||||
// simple one to the user.
|
// simple one to the user.
|
||||||
ConsoleService::error(&format!("Invalid username or password: {}", e));
|
error!(&format!("Invalid username or password: {}", e));
|
||||||
self.common.error = Some(anyhow!("Invalid username or password"));
|
self.common.error = Some(anyhow!("Invalid username or password"));
|
||||||
self.common.cancel_task();
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
|
@ -88,24 +94,22 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||||
credential_finalization: login_finish.message,
|
credential_finalization: login_finish.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::login_finish,
|
ctx,
|
||||||
req,
|
HostService::login_finish(req),
|
||||||
Msg::AuthenticationFinishResponse,
|
Msg::AuthenticationFinishResponse,
|
||||||
)?;
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationFinishResponse(user_info) => {
|
Msg::AuthenticationFinishResponse(user_info) => {
|
||||||
self.common.cancel_task();
|
ctx.props()
|
||||||
self.common
|
|
||||||
.on_logged_in
|
.on_logged_in
|
||||||
.emit(user_info.context("Could not log in")?);
|
.emit(user_info.context("Could not log in")?);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationRefreshResponse(user_info) => {
|
Msg::AuthenticationRefreshResponse(user_info) => {
|
||||||
self.refreshing = false;
|
self.refreshing = false;
|
||||||
self.common.cancel_task();
|
|
||||||
if let Ok(user_info) = user_info {
|
if let Ok(user_info) = user_info {
|
||||||
self.common.on_logged_in.emit(user_info);
|
ctx.props().on_logged_in.emit(user_info);
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
@ -121,32 +125,28 @@ impl Component for LoginForm {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut app = LoginForm {
|
let mut app = LoginForm {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: Form::<FormModel>::new(FormModel::default()),
|
form: Form::<FormModel>::new(FormModel::default()),
|
||||||
refreshing: true,
|
refreshing: true,
|
||||||
};
|
};
|
||||||
if let Err(e) =
|
app.common.call_backend(
|
||||||
app.common
|
ctx,
|
||||||
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse)
|
HostService::refresh(),
|
||||||
{
|
Msg::AuthenticationRefreshResponse,
|
||||||
ConsoleService::debug(&format!("Could not refresh auth: {}", e));
|
);
|
||||||
app.refreshing = false;
|
|
||||||
}
|
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<FormModel>;
|
type Field = yew_form::Field<FormModel>;
|
||||||
|
let password_reset_enabled = ctx.props().password_reset_enabled;
|
||||||
|
let link = &ctx.link();
|
||||||
if self.refreshing {
|
if self.refreshing {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
|
@ -167,11 +167,11 @@ impl Component for LoginForm {
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="username"
|
field_name="username"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="input-group-prepend">
|
<div class="input-group-prepend">
|
||||||
|
@ -183,7 +183,7 @@ impl Component for LoginForm {
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="password"
|
field_name="password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
|
@ -193,17 +193,23 @@ impl Component for LoginForm {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||||
<i class="bi-box-arrow-in-right me-2"/>
|
<i class="bi-box-arrow-in-right me-2"/>
|
||||||
{"Login"}
|
{"Login"}
|
||||||
</button>
|
</button>
|
||||||
<NavButton
|
{ if password_reset_enabled {
|
||||||
|
html! {
|
||||||
|
<Link
|
||||||
classes="btn-link btn"
|
classes="btn-link btn"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
route=AppRoute::StartResetPassword>
|
to={AppRoute::StartResetPassword}>
|
||||||
{"Forgot your password?"}
|
{"Forgot your password?"}
|
||||||
</NavButton>
|
</Link>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html!{}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
|
|
|
@ -21,16 +21,20 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<LogoutButton> for LogoutButton {
|
impl CommonComponent<LogoutButton> for LogoutButton {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::LogoutRequested => {
|
Msg::LogoutRequested => {
|
||||||
self.common
|
self.common
|
||||||
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
|
.call_backend(ctx, HostService::logout(), Msg::LogoutCompleted);
|
||||||
}
|
}
|
||||||
Msg::LogoutCompleted(res) => {
|
Msg::LogoutCompleted(res) => {
|
||||||
res?;
|
res?;
|
||||||
delete_cookie("user_id")?;
|
delete_cookie("user_id")?;
|
||||||
self.common.on_logged_out.emit(());
|
ctx.props().on_logged_out.emit(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
|
@ -45,25 +49,22 @@ impl Component for LogoutButton {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
LogoutButton {
|
LogoutButton {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
onclick={link.callback(|_| Msg::LogoutRequested)}>
|
||||||
{"Logout"}
|
{"Logout"}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,15 +31,18 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
Msg::SubmitRemoveGroup => self.submit_remove_group(ctx),
|
||||||
Msg::RemoveGroupResponse(response) => {
|
Msg::RemoveGroupResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.common.cancel_task();
|
ctx.props()
|
||||||
self.common
|
|
||||||
.on_user_removed_from_group
|
.on_user_removed_from_group
|
||||||
.emit((self.common.username.clone(), self.common.group_id));
|
.emit((ctx.props().username.clone(), ctx.props().group_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
@ -51,11 +54,12 @@ impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupCompon
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RemoveUserFromGroupComponent {
|
impl RemoveUserFromGroupComponent {
|
||||||
fn submit_remove_group(&mut self) {
|
fn submit_remove_group(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
||||||
|
ctx,
|
||||||
remove_user_from_group::Variables {
|
remove_user_from_group::Variables {
|
||||||
user: self.common.username.clone(),
|
user: ctx.props().username.clone(),
|
||||||
group: self.common.group_id,
|
group: ctx.props().group_id,
|
||||||
},
|
},
|
||||||
Msg::RemoveGroupResponse,
|
Msg::RemoveGroupResponse,
|
||||||
"Error trying to initiate removing the user from a group",
|
"Error trying to initiate removing the user from a group",
|
||||||
|
@ -67,30 +71,28 @@ impl Component for RemoveUserFromGroupComponent {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
onclick={link.callback(|_| Msg::SubmitRemoveGroup)}>
|
||||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::router::{AppRoute, Link},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
@ -31,7 +31,11 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
|
@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||||
}
|
}
|
||||||
let FormModel { username } = self.form.model();
|
let FormModel { username } = self.form.model();
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::reset_password_step1,
|
ctx,
|
||||||
&username,
|
HostService::reset_password_step1(username),
|
||||||
Msg::PasswordResetResponse,
|
Msg::PasswordResetResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::PasswordResetResponse(response) => {
|
Msg::PasswordResetResponse(response) => {
|
||||||
|
@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
ResetPasswordStep1Form {
|
ResetPasswordStep1Form {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: Form::<FormModel>::new(FormModel::default()),
|
form: Form::<FormModel>::new(FormModel::default()),
|
||||||
just_succeeded: false,
|
just_succeeded: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
self.just_succeeded = false;
|
self.just_succeeded = false;
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<FormModel>;
|
type Field = yew_form::Field<FormModel>;
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<form
|
<form
|
||||||
class="form center-block col-sm-4 col-offset-4">
|
class="form center-block col-sm-4 col-offset-4">
|
||||||
|
@ -95,11 +96,11 @@ impl Component for ResetPasswordStep1Form {
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="username"
|
field_name="username"
|
||||||
placeholder="Username or email"
|
placeholder="Username or email"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</div>
|
</div>
|
||||||
{ if self.just_succeeded {
|
{ if self.just_succeeded {
|
||||||
html! {
|
html! {
|
||||||
|
@ -111,17 +112,17 @@ impl Component for ResetPasswordStep1Form {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||||
<i class="bi-check-circle me-2"/>
|
<i class="bi-check-circle me-2"/>
|
||||||
{"Reset password"}
|
{"Reset password"}
|
||||||
</button>
|
</button>
|
||||||
<NavButton
|
<Link
|
||||||
classes="btn-link btn"
|
classes="btn-link btn"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
route=AppRoute::Login>
|
to={AppRoute::Login}>
|
||||||
{"Back"}
|
{"Back"}
|
||||||
</NavButton>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
components::router::AppRoute,
|
components::router::{AppRoute, Link},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Result};
|
||||||
use lldap_auth::{
|
use lldap_auth::{
|
||||||
opaque::client::registration as opaque_registration,
|
opaque::client::registration as opaque_registration,
|
||||||
password_reset::ServerPasswordResetResponse, registration,
|
password_reset::ServerPasswordResetResponse, registration,
|
||||||
|
@ -14,10 +14,7 @@ use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The fields of the form, with the constraints.
|
/// The fields of the form, with the constraints.
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
|
@ -33,7 +30,6 @@ pub struct ResetPasswordStep2Form {
|
||||||
form: Form<FormModel>,
|
form: Form<FormModel>,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
opaque_data: Option<opaque_registration::ClientRegistration>,
|
opaque_data: Option<opaque_registration::ClientRegistration>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||||
|
@ -50,11 +46,15 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use anyhow::Context;
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ValidateTokenResponse(response) => {
|
Msg::ValidateTokenResponse(response) => {
|
||||||
self.username = Some(response?.user_id);
|
self.username = Some(response?.user_id);
|
||||||
self.common.cancel_task();
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::FormUpdate => Ok(true),
|
Msg::FormUpdate => Ok(true),
|
||||||
|
@ -73,10 +73,10 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||||
};
|
};
|
||||||
self.opaque_data = Some(registration_start_request.state);
|
self.opaque_data = Some(registration_start_request.state);
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_start,
|
ctx,
|
||||||
req,
|
HostService::register_start(req),
|
||||||
Msg::RegistrationStartResponse,
|
Msg::RegistrationStartResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::RegistrationStartResponse(res) => {
|
Msg::RegistrationStartResponse(res) => {
|
||||||
|
@ -94,17 +94,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||||
registration_upload: registration_finish.message,
|
registration_upload: registration_finish.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_finish,
|
ctx,
|
||||||
req,
|
HostService::register_finish(req),
|
||||||
Msg::RegistrationFinishResponse,
|
Msg::RegistrationFinishResponse,
|
||||||
)?;
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
if response.is_ok() {
|
if response.is_ok() {
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::Login);
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
|
|
||||||
}
|
}
|
||||||
response?;
|
response?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
@ -121,35 +119,28 @@ impl Component for ResetPasswordStep2Form {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut component = ResetPasswordStep2Form {
|
let mut component = ResetPasswordStep2Form {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||||
opaque_data: None,
|
opaque_data: None,
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
username: None,
|
username: None,
|
||||||
};
|
};
|
||||||
let token = component.common.token.clone();
|
let token = ctx.props().token.clone();
|
||||||
component
|
component.common.call_backend(
|
||||||
.common
|
ctx,
|
||||||
.call_backend(
|
HostService::reset_password_step2(token),
|
||||||
HostService::reset_password_step2,
|
|
||||||
&token,
|
|
||||||
Msg::ValidateTokenResponse,
|
Msg::ValidateTokenResponse,
|
||||||
)
|
);
|
||||||
.unwrap();
|
|
||||||
component
|
component
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
match (&self.username, &self.common.error) {
|
match (&self.username, &self.common.error) {
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
return html! {
|
return html! {
|
||||||
|
@ -158,9 +149,17 @@ impl Component for ResetPasswordStep2Form {
|
||||||
}
|
}
|
||||||
(None, Some(e)) => {
|
(None, Some(e)) => {
|
||||||
return html! {
|
return html! {
|
||||||
|
<>
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
{e.to_string() }
|
{e.to_string() }
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
classes="btn-link btn"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
to={AppRoute::Login}>
|
||||||
|
{"Back"}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
|
@ -178,14 +177,14 @@ impl Component for ResetPasswordStep2Form {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="password"
|
field_name="password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("password")}
|
{&self.form.field_message("password")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -198,14 +197,14 @@ impl Component for ResetPasswordStep2Form {
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<Field
|
<Field
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="confirm_password"
|
field_name="confirm_password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("confirm_password")}
|
{&self.form.field_message("confirm_password")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -215,8 +214,8 @@ impl Component for ResetPasswordStep2Form {
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary col-sm-1 col-form-label"
|
class="btn btn-primary col-sm-1 col-form-label"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||||
{"Submit"}
|
{"Submit"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,34 +1,30 @@
|
||||||
use yew_router::{
|
use yew_router::Routable;
|
||||||
components::{RouterAnchor, RouterButton},
|
|
||||||
Switch,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Switch, Debug, Clone)]
|
#[derive(Routable, Debug, Clone, PartialEq)]
|
||||||
pub enum AppRoute {
|
pub enum AppRoute {
|
||||||
#[to = "/login"]
|
#[at("/login")]
|
||||||
Login,
|
Login,
|
||||||
#[to = "/reset-password/step1"]
|
#[at("/reset-password/step1")]
|
||||||
StartResetPassword,
|
StartResetPassword,
|
||||||
#[to = "/reset-password/step2/{token}"]
|
#[at("/reset-password/step2/:token")]
|
||||||
FinishResetPassword(String),
|
FinishResetPassword { token: String },
|
||||||
#[to = "/users/create"]
|
#[at("/users/create")]
|
||||||
CreateUser,
|
CreateUser,
|
||||||
#[to = "/users"]
|
#[at("/users")]
|
||||||
ListUsers,
|
ListUsers,
|
||||||
#[to = "/user/{user_id}/password"]
|
#[at("/user/:user_id/password")]
|
||||||
ChangePassword(String),
|
ChangePassword { user_id: String },
|
||||||
#[to = "/user/{user_id}"]
|
#[at("/user/:user_id")]
|
||||||
UserDetails(String),
|
UserDetails { user_id: String },
|
||||||
#[to = "/groups/create"]
|
#[at("/groups/create")]
|
||||||
CreateGroup,
|
CreateGroup,
|
||||||
#[to = "/groups"]
|
#[at("/groups")]
|
||||||
ListGroups,
|
ListGroups,
|
||||||
#[to = "/group/{group_id}"]
|
#[at("/group/:group_id")]
|
||||||
GroupDetails(i64),
|
GroupDetails { group_id: i64 },
|
||||||
#[to = "/"]
|
#[at("/")]
|
||||||
Index,
|
Index,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Link = RouterAnchor<AppRoute>;
|
pub type Link = yew_router::components::Link<AppRoute>;
|
||||||
|
pub type Redirect = yew_router::components::Redirect<AppRoute>;
|
||||||
pub type NavButton = RouterButton<AppRoute>;
|
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
use yew::{html::ChangeData, prelude::*};
|
use yew::prelude::*;
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
pub struct Select {
|
pub struct Select {
|
||||||
link: ComponentLink<Self>,
|
|
||||||
props: SelectProps,
|
|
||||||
node_ref: NodeRef,
|
node_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,100 +11,70 @@ pub struct SelectProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum SelectMsg {
|
pub enum SelectMsg {
|
||||||
OnSelectChange(ChangeData),
|
OnSelectChange,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Select {
|
impl Select {
|
||||||
fn get_nth_child_props(&self, nth: i32) -> Option<SelectOptionProps> {
|
fn get_nth_child_props(&self, ctx: &Context<Self>, nth: i32) -> Option<SelectOptionProps> {
|
||||||
if nth == -1 {
|
if nth == -1 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.props
|
ctx.props()
|
||||||
.children
|
.children
|
||||||
.iter()
|
.iter()
|
||||||
.nth(nth as usize)
|
.nth(nth as usize)
|
||||||
.map(|child| child.props)
|
.map(|child| (*child.props).clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_selection_update(&self) {
|
fn send_selection_update(&self, ctx: &Context<Self>) {
|
||||||
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
|
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
|
||||||
self.props
|
ctx.props()
|
||||||
.on_selection_change
|
.on_selection_change
|
||||||
.emit(self.get_nth_child_props(select_node.selected_index()))
|
.emit(self.get_nth_child_props(ctx, select_node.selected_index()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for Select {
|
impl Component for Select {
|
||||||
type Message = SelectMsg;
|
type Message = SelectMsg;
|
||||||
type Properties = SelectProps;
|
type Properties = SelectProps;
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
link,
|
|
||||||
props,
|
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered(&mut self, _first_render: bool) {
|
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
|
||||||
self.send_selection_update();
|
self.send_selection_update(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, _: Self::Message) -> bool {
|
||||||
let SelectMsg::OnSelectChange(data) = msg;
|
self.send_selection_update(ctx);
|
||||||
match data {
|
|
||||||
ChangeData::Select(_) => self.send_selection_update(),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.props.children.neq_assign(props.children)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<select class="form-select"
|
<select class="form-select"
|
||||||
ref=self.node_ref.clone()
|
ref={self.node_ref.clone()}
|
||||||
disabled=self.props.children.is_empty()
|
disabled={ctx.props().children.is_empty()}
|
||||||
onchange=self.link.callback(SelectMsg::OnSelectChange)>
|
onchange={ctx.link().callback(|_| SelectMsg::OnSelectChange)}>
|
||||||
{ self.props.children.clone() }
|
{ ctx.props().children.clone() }
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SelectOption {
|
|
||||||
props: SelectOptionProps,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
|
||||||
pub struct SelectOptionProps {
|
pub struct SelectOptionProps {
|
||||||
pub value: String,
|
pub value: String,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for SelectOption {
|
#[function_component(SelectOption)]
|
||||||
type Message = ();
|
pub fn select_option(props: &SelectOptionProps) -> Html {
|
||||||
type Properties = SelectOptionProps;
|
|
||||||
|
|
||||||
fn create(props: Self::Properties, _: ComponentLink<Self>) -> Self {
|
|
||||||
Self { props }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, _: Self::Message) -> ShouldRender {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
|
||||||
self.props.neq_assign(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<option value=self.props.value.clone()>
|
<option value={props.value.clone()}>
|
||||||
{&self.props.text}
|
{&props.text}
|
||||||
</option>
|
</option>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ use crate::{
|
||||||
components::{
|
components::{
|
||||||
add_user_to_group::AddUserToGroupComponent,
|
add_user_to_group::AddUserToGroupComponent,
|
||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link, NavButton},
|
router::{AppRoute, Link},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
},
|
},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
@ -47,7 +47,7 @@ pub struct Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetails> for UserDetails {
|
impl CommonComponent<UserDetails> for UserDetails {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserDetailsResponse(response) => match response {
|
Msg::UserDetailsResponse(response) => match response {
|
||||||
Ok(user) => self.user = Some(user.user),
|
Ok(user) => self.user = Some(user.user),
|
||||||
|
@ -77,10 +77,11 @@ impl CommonComponent<UserDetails> for UserDetails {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserDetails {
|
impl UserDetails {
|
||||||
fn get_user_details(&mut self) {
|
fn get_user_details(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<GetUserDetails, _>(
|
self.common.call_graphql::<GetUserDetails, _>(
|
||||||
|
ctx,
|
||||||
get_user_details::Variables {
|
get_user_details::Variables {
|
||||||
id: self.common.username.clone(),
|
id: ctx.props().username.clone(),
|
||||||
},
|
},
|
||||||
Msg::UserDetailsResponse,
|
Msg::UserDetailsResponse,
|
||||||
"Error trying to fetch user details",
|
"Error trying to fetch user details",
|
||||||
|
@ -99,24 +100,25 @@ impl UserDetails {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_group_memberships(&self, u: &User) -> Html {
|
fn view_group_memberships(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
let make_group_row = |group: &Group| {
|
let make_group_row = |group: &Group| {
|
||||||
let display_name = group.display_name.clone();
|
let display_name = group.display_name.clone();
|
||||||
html! {
|
html! {
|
||||||
<tr key="groupRow_".to_string() + &display_name>
|
<tr key={"groupRow_".to_string() + &display_name}>
|
||||||
{if self.common.is_admin { html! {
|
{if ctx.props().is_admin { html! {
|
||||||
<>
|
<>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::GroupDetails(group.id)>
|
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||||
{&group.display_name}
|
{&group.display_name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<RemoveUserFromGroupComponent
|
<RemoveUserFromGroupComponent
|
||||||
username=u.id.clone()
|
username={u.id.clone()}
|
||||||
group_id=group.id
|
group_id={group.id}
|
||||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
} } else { html! {
|
} } else { html! {
|
||||||
|
@ -133,7 +135,7 @@ impl UserDetails {
|
||||||
<thead>
|
<thead>
|
||||||
<tr key="headerRow">
|
<tr key="headerRow">
|
||||||
<th>{"Group"}</th>
|
<th>{"Group"}</th>
|
||||||
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
{ if ctx.props().is_admin { html!{ <th></th> }} else { html!{} }}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -153,14 +155,15 @@ impl UserDetails {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_add_group_button(&self, u: &User) -> Html {
|
fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||||
if self.common.is_admin {
|
let link = &ctx.link();
|
||||||
|
if ctx.props().is_admin {
|
||||||
html! {
|
html! {
|
||||||
<AddUserToGroupComponent
|
<AddUserToGroupComponent
|
||||||
username=u.id.clone()
|
username={u.id.clone()}
|
||||||
groups=u.groups.clone()
|
groups={u.groups.clone()}
|
||||||
on_error=self.common.callback(Msg::OnError)
|
on_error={link.callback(Msg::OnError)}
|
||||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
|
@ -172,24 +175,20 @@ impl Component for UserDetails {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
user: None,
|
user: None,
|
||||||
};
|
};
|
||||||
table.get_user_details();
|
table.get_user_details(ctx);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
match (&self.user, &self.common.error) {
|
match (&self.user, &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>},
|
||||||
|
@ -198,20 +197,19 @@ impl Component for UserDetails {
|
||||||
<>
|
<>
|
||||||
<h3>{u.id.to_string()}</h3>
|
<h3>{u.id.to_string()}</h3>
|
||||||
<div class="d-flex flex-row-reverse">
|
<div class="d-flex flex-row-reverse">
|
||||||
<NavButton
|
<Link
|
||||||
route=AppRoute::ChangePassword(u.id.clone())
|
to={AppRoute::ChangePassword{user_id: u.id.clone()}}
|
||||||
classes="btn btn-secondary">
|
classes="btn btn-secondary">
|
||||||
<i class="bi-key me-2"></i>
|
<i class="bi-key me-2"></i>
|
||||||
{"Modify password"}
|
{"Modify password"}
|
||||||
</NavButton>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<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() />
|
{self.view_group_memberships(ctx, u)}
|
||||||
{self.view_group_memberships(u)}
|
{self.view_add_group_button(ctx, u)}
|
||||||
{self.view_add_group_button(u)}
|
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,15 +5,19 @@ use crate::{
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
|
use gloo_file::{
|
||||||
|
callbacks::{read_as_bytes, FileReader},
|
||||||
|
File,
|
||||||
|
};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use wasm_bindgen::JsCast;
|
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||||
use yew::{prelude::*, services::ConsoleService};
|
use yew::prelude::*;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Clone, Default)]
|
#[derive(Default)]
|
||||||
struct JsFile {
|
struct JsFile {
|
||||||
file: Option<web_sys::File>,
|
file: Option<File>,
|
||||||
contents: Option<Vec<u8>>,
|
contents: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,7 +25,7 @@ impl ToString for JsFile {
|
||||||
fn to_string(&self) -> String {
|
fn to_string(&self) -> String {
|
||||||
self.file
|
self.file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(web_sys::File::name)
|
.map(File::name)
|
||||||
.unwrap_or_else(String::new)
|
.unwrap_or_else(String::new)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -64,17 +68,21 @@ pub struct UserDetailsForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<UserModel>,
|
form: yew_form::Form<UserModel>,
|
||||||
avatar: JsFile,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
/// A picked file finished loading.
|
||||||
FileLoaded(yew::services::reader::FileData),
|
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>),
|
||||||
}
|
}
|
||||||
|
@ -86,53 +94,47 @@ pub struct Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => {
|
Msg::Update => Ok(true),
|
||||||
let window = web_sys::window().expect("no global `window` exists");
|
Msg::FileSelected(new_avatar) => {
|
||||||
let document = window.document().expect("should have a document on window");
|
if self.avatar.file.as_ref().map(|f| f.name()) != Some(new_avatar.name()) {
|
||||||
let input = document
|
let file_name = new_avatar.name();
|
||||||
.get_element_by_id("avatarInput")
|
let link = ctx.link().clone();
|
||||||
.expect("Form field avatarInput should be present")
|
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||||
.dyn_into::<web_sys::HtmlInputElement>()
|
link.send_message(Msg::FileLoaded(
|
||||||
.expect("Should be an HtmlInputElement");
|
file_name,
|
||||||
ConsoleService::log("Form update");
|
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||||
if let Some(files) = input.files() {
|
))
|
||||||
ConsoleService::log("Got file list");
|
}));
|
||||||
if files.length() > 0 {
|
self.avatar = JsFile {
|
||||||
ConsoleService::log("Got a file");
|
file: Some(new_avatar),
|
||||||
let new_avatar = JsFile {
|
|
||||||
file: files.item(0),
|
|
||||||
contents: None,
|
contents: None,
|
||||||
};
|
};
|
||||||
if self.avatar.file.as_ref().map(|f| f.name())
|
|
||||||
!= new_avatar.file.as_ref().map(|f| f.name())
|
|
||||||
{
|
|
||||||
if let Some(ref file) = new_avatar.file {
|
|
||||||
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
|
|
||||||
}
|
|
||||||
self.avatar = new_avatar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||||
Msg::FileLoaded(data) => {
|
Msg::FileLoaded(file_name, data) => {
|
||||||
self.common.cancel_task();
|
|
||||||
if let Some(file) = &self.avatar.file {
|
if let Some(file) = &self.avatar.file {
|
||||||
if file.name() == data.name {
|
if file.name() == file_name {
|
||||||
if !is_valid_jpeg(data.content.as_slice()) {
|
let data = data?;
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
// Clear the selection.
|
// Clear the selection.
|
||||||
self.avatar = JsFile::default();
|
self.avatar = JsFile::default();
|
||||||
bail!("Chosen image is not a valid JPEG");
|
bail!("Chosen image is not a valid JPEG");
|
||||||
} else {
|
} else {
|
||||||
self.avatar.contents = Some(data.content);
|
self.avatar.contents = Some(data);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.reader = None;
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -147,37 +149,36 @@ impl Component for UserDetailsForm {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let model = UserModel {
|
let model = UserModel {
|
||||||
email: props.user.email.clone(),
|
email: ctx.props().user.email.clone(),
|
||||||
display_name: props.user.display_name.clone(),
|
display_name: ctx.props().user.display_name.clone(),
|
||||||
first_name: props.user.first_name.clone(),
|
first_name: ctx.props().user.first_name.clone(),
|
||||||
last_name: props.user.last_name.clone(),
|
last_name: ctx.props().user.last_name.clone(),
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::new(model),
|
form: yew_form::Form::new(model),
|
||||||
avatar: JsFile::default(),
|
avatar: JsFile::default(),
|
||||||
just_updated: false,
|
just_updated: false,
|
||||||
|
reader: None,
|
||||||
|
user: ctx.props().user.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
self.just_updated = false;
|
self.just_updated = false;
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<UserModel>;
|
type Field = yew_form::Field<UserModel>;
|
||||||
|
let link = &ctx.link();
|
||||||
|
|
||||||
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
|
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
|
||||||
let avatar_string = avatar_base64
|
let avatar_string = avatar_base64
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(self.common.user.avatar.as_deref())
|
.or(self.user.avatar.as_deref())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
html! {
|
html! {
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
|
@ -188,7 +189,7 @@ impl Component for UserDetailsForm {
|
||||||
{"User ID: "}
|
{"User ID: "}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<span id="userId" class="form-control-static"><i>{&self.common.user.id}</i></span>
|
<span id="userId" class="form-control-static"><i>{&self.user.id}</i></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group row mb-3">
|
<div class="form-group row mb-3">
|
||||||
|
@ -197,7 +198,7 @@ impl Component for UserDetailsForm {
|
||||||
{"Creation date: "}
|
{"Creation date: "}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<span id="creationDate" class="form-control-static">{&self.common.user.creation_date.naive_local().date()}</span>
|
<span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group row mb-3">
|
<div class="form-group row mb-3">
|
||||||
|
@ -206,7 +207,7 @@ impl Component for UserDetailsForm {
|
||||||
{"UUID: "}
|
{"UUID: "}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<span id="creationDate" class="form-control-static">{&self.common.user.uuid}</span>
|
<span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group row mb-3">
|
<div class="form-group row mb-3">
|
||||||
|
@ -221,10 +222,10 @@ impl Component for UserDetailsForm {
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="email"
|
field_name="email"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("email")}
|
{&self.form.field_message("email")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -240,10 +241,10 @@ impl Component for UserDetailsForm {
|
||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="display_name"
|
field_name="display_name"
|
||||||
autocomplete="name"
|
autocomplete="name"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("display_name")}
|
{&self.form.field_message("display_name")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -257,10 +258,10 @@ impl Component for UserDetailsForm {
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
class="form-control"
|
class="form-control"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="first_name"
|
field_name="first_name"
|
||||||
autocomplete="given-name"
|
autocomplete="given-name"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("first_name")}
|
{&self.form.field_message("first_name")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -274,10 +275,10 @@ impl Component for UserDetailsForm {
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<Field
|
<Field
|
||||||
class="form-control"
|
class="form-control"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="last_name"
|
field_name="last_name"
|
||||||
autocomplete="family-name"
|
autocomplete="family-name"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("last_name")}
|
{&self.form.field_message("last_name")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -296,7 +297,10 @@ impl Component for UserDetailsForm {
|
||||||
id="avatarInput"
|
id="avatarInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg"
|
accept="image/jpeg"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
Self::upload_files(input.files())
|
||||||
|
})} />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<img
|
<img
|
||||||
|
@ -312,8 +316,8 @@ impl Component for UserDetailsForm {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary col-auto col-form-label"
|
class="btn btn-primary col-auto col-form-label"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
|
||||||
<i class="bi-save me-2"></i>
|
<i class="bi-save me-2"></i>
|
||||||
{"Save changes"}
|
{"Save changes"}
|
||||||
</button>
|
</button>
|
||||||
|
@ -328,7 +332,7 @@ impl Component for UserDetailsForm {
|
||||||
}
|
}
|
||||||
} else { html! {} }
|
} else { html! {} }
|
||||||
}
|
}
|
||||||
<div hidden=!self.just_updated>
|
<div hidden={!self.just_updated}>
|
||||||
<div class="alert alert-success mt-4">{"User successfully updated!"}</div>
|
<div class="alert alert-success mt-4">{"User successfully updated!"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -337,12 +341,10 @@ impl Component for UserDetailsForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserDetailsForm {
|
impl UserDetailsForm {
|
||||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
ConsoleService::log("Submit");
|
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
bail!("Invalid inputs");
|
bail!("Invalid inputs");
|
||||||
}
|
}
|
||||||
ConsoleService::log("Valid inputs");
|
|
||||||
if let JsFile {
|
if let JsFile {
|
||||||
file: Some(_),
|
file: Some(_),
|
||||||
contents: None,
|
contents: None,
|
||||||
|
@ -350,10 +352,9 @@ impl UserDetailsForm {
|
||||||
{
|
{
|
||||||
bail!("Image file hasn't finished loading, try again");
|
bail!("Image file hasn't finished loading, try again");
|
||||||
}
|
}
|
||||||
ConsoleService::log("File is correctly loaded");
|
let base_user = &self.user;
|
||||||
let base_user = &self.common.user;
|
|
||||||
let mut user_input = update_user::UpdateUserInput {
|
let mut user_input = update_user::UpdateUserInput {
|
||||||
id: self.common.user.id.clone(),
|
id: self.user.id.clone(),
|
||||||
email: None,
|
email: None,
|
||||||
displayName: None,
|
displayName: None,
|
||||||
firstName: None,
|
firstName: None,
|
||||||
|
@ -378,12 +379,11 @@ impl UserDetailsForm {
|
||||||
user_input.avatar = maybe_to_base64(&self.avatar)?;
|
user_input.avatar = maybe_to_base64(&self.avatar)?;
|
||||||
// Nothing changed.
|
// Nothing changed.
|
||||||
if user_input == default_user_input {
|
if user_input == default_user_input {
|
||||||
ConsoleService::log("No changes");
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let req = update_user::Variables { user: user_input };
|
let req = update_user::Variables { user: user_input };
|
||||||
ConsoleService::log("Querying");
|
|
||||||
self.common.call_graphql::<UpdateUser, _>(
|
self.common.call_graphql::<UpdateUser, _>(
|
||||||
|
ctx,
|
||||||
req,
|
req,
|
||||||
Msg::UserUpdated,
|
Msg::UserUpdated,
|
||||||
"Error trying to update user",
|
"Error trying to update user",
|
||||||
|
@ -392,23 +392,30 @@ impl UserDetailsForm {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||||
self.common.cancel_task();
|
r?;
|
||||||
match r {
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
Ok(_) => {
|
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
self.common.user.email = model.email;
|
self.user.email = model.email;
|
||||||
self.common.user.display_name = model.display_name;
|
self.user.display_name = model.display_name;
|
||||||
self.common.user.first_name = model.first_name;
|
self.user.first_name = model.first_name;
|
||||||
self.common.user.last_name = model.last_name;
|
self.user.last_name = model.last_name;
|
||||||
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
|
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
|
||||||
self.common.user.avatar = Some(avatar);
|
self.user.avatar = Some(avatar);
|
||||||
}
|
}
|
||||||
self.just_updated = true;
|
self.just_updated = true;
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(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 {
|
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||||
|
|
|
@ -34,7 +34,7 @@ pub enum Msg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserTable> for UserTable {
|
impl CommonComponent<UserTable> for UserTable {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ListUsersResponse(users) => {
|
Msg::ListUsersResponse(users) => {
|
||||||
self.users = Some(users?.users.into_iter().collect());
|
self.users = Some(users?.users.into_iter().collect());
|
||||||
|
@ -55,8 +55,9 @@ impl CommonComponent<UserTable> for UserTable {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserTable {
|
impl UserTable {
|
||||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
fn get_users(&mut self, ctx: &Context<Self>, req: Option<RequestFilter>) {
|
||||||
self.common.call_graphql::<ListUsersQuery, _>(
|
self.common.call_graphql::<ListUsersQuery, _>(
|
||||||
|
ctx,
|
||||||
list_users_query::Variables { filters: req },
|
list_users_query::Variables { filters: req },
|
||||||
Msg::ListUsersResponse,
|
Msg::ListUsersResponse,
|
||||||
"Error trying to fetch users",
|
"Error trying to fetch users",
|
||||||
|
@ -68,27 +69,23 @@ impl Component for UserTable {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = UserTable {
|
let mut table = UserTable {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
users: None,
|
users: None,
|
||||||
};
|
};
|
||||||
table.get_users(None);
|
table.get_users(ctx, None);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_users()}
|
{self.view_users(ctx)}
|
||||||
{self.view_errors()}
|
{self.view_errors()}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -96,7 +93,7 @@ impl Component for UserTable {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserTable {
|
impl UserTable {
|
||||||
fn view_users(&self) -> Html {
|
fn view_users(&self, ctx: &Context<Self>) -> Html {
|
||||||
let make_table = |users: &Vec<User>| {
|
let make_table = |users: &Vec<User>| {
|
||||||
html! {
|
html! {
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
|
@ -113,7 +110,7 @@ impl UserTable {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.iter().map(|u| self.view_user(u)).collect::<Vec<_>>()}
|
{users.iter().map(|u| self.view_user(ctx, u)).collect::<Vec<_>>()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@ -125,10 +122,11 @@ impl UserTable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_user(&self, user: &User) -> Html {
|
fn view_user(&self, ctx: &Context<Self>, user: &User) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<tr key=user.id.clone()>
|
<tr key={user.id.clone()}>
|
||||||
<td><Link route=AppRoute::UserDetails(user.id.clone())>{&user.id}</Link></td>
|
<td><Link to={AppRoute::UserDetails{user_id: user.id.clone()}}>{&user.id}</Link></td>
|
||||||
<td>{&user.email}</td>
|
<td>{&user.email}</td>
|
||||||
<td>{&user.display_name}</td>
|
<td>{&user.display_name}</td>
|
||||||
<td>{&user.first_name}</td>
|
<td>{&user.first_name}</td>
|
||||||
|
@ -136,9 +134,9 @@ impl UserTable {
|
||||||
<td>{&user.creation_date.naive_local().date()}</td>
|
<td>{&user.creation_date.naive_local().date()}</td>
|
||||||
<td>
|
<td>
|
||||||
<DeleteUser
|
<DeleteUser
|
||||||
username=user.id.clone()
|
username={user.id.clone()}
|
||||||
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
on_user_deleted={link.callback(Msg::OnUserDeleted)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,136 +1,84 @@
|
||||||
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, Request};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{login, registration, JWTClaims};
|
use lldap_auth::{login, registration, JWTClaims};
|
||||||
|
|
||||||
use yew::callback::Callback;
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use yew::format::Json;
|
use web_sys::RequestCredentials;
|
||||||
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct HostService {}
|
pub struct HostService {}
|
||||||
|
|
||||||
fn get_default_options() -> FetchOptions {
|
|
||||||
FetchOptions {
|
|
||||||
credentials: Some(Credentials::SameOrigin),
|
|
||||||
..FetchOptions::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||||
use jwt::*;
|
use jwt::*;
|
||||||
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
||||||
Ok(token.claims().clone())
|
Ok(token.claims().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_handler<Resp, CallbackResult, F>(
|
const NO_BODY: Option<()> = None;
|
||||||
callback: Callback<Result<CallbackResult>>,
|
|
||||||
handler: F,
|
|
||||||
) -> Callback<Response<Result<Resp>>>
|
|
||||||
where
|
|
||||||
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
|
|
||||||
CallbackResult: 'static,
|
|
||||||
{
|
|
||||||
Callback::once(move |response: Response<Result<Resp>>| {
|
|
||||||
let (meta, maybe_data) = response.into_parts();
|
|
||||||
let message = maybe_data
|
|
||||||
.context("Could not reach server")
|
|
||||||
.and_then(|data| handler(meta.status, data));
|
|
||||||
callback.emit(message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RequestBody<T>(T);
|
async fn call_server(
|
||||||
|
|
||||||
impl<'a, R> From<&'a R> for RequestBody<Json<&'a R>>
|
|
||||||
where
|
|
||||||
R: serde::ser::Serialize,
|
|
||||||
{
|
|
||||||
fn from(request: &'a R) -> Self {
|
|
||||||
Self(Json(request))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<yew::format::Nothing> for RequestBody<yew::format::Nothing> {
|
|
||||||
fn from(request: yew::format::Nothing) -> Self {
|
|
||||||
Self(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_server<Req, CallbackResult, F, RB>(
|
|
||||||
url: &str,
|
url: &str,
|
||||||
request: RB,
|
body: Option<impl Serialize>,
|
||||||
callback: Callback<Result<CallbackResult>>,
|
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
parse_response: F,
|
) -> Result<String> {
|
||||||
) -> Result<FetchTask>
|
let mut request = Request::new(url)
|
||||||
where
|
|
||||||
F: Fn(String) -> Result<CallbackResult> + 'static,
|
|
||||||
CallbackResult: 'static,
|
|
||||||
RB: Into<RequestBody<Req>>,
|
|
||||||
Req: Into<yew::format::Text>,
|
|
||||||
{
|
|
||||||
let request = {
|
|
||||||
// If the request type is empty (if the size is 0), it's a get.
|
|
||||||
if std::mem::size_of::<RB>() == 0 {
|
|
||||||
Request::get(url)
|
|
||||||
} else {
|
|
||||||
Request::post(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(request.into().0)?;
|
.credentials(RequestCredentials::SameOrigin);
|
||||||
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
|
if let Some(b) = body {
|
||||||
if status.is_success() {
|
request = request
|
||||||
parse_response(data)
|
.body(serde_json::to_string(&b)?)
|
||||||
|
.method(Method::POST);
|
||||||
|
}
|
||||||
|
let response = request.send().await?;
|
||||||
|
if response.ok() {
|
||||||
|
Ok(response.text().await?)
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("{}[{}]: {}", error_message, status, data))
|
Err(anyhow!(
|
||||||
}
|
"{}[{} {}]: {}",
|
||||||
});
|
|
||||||
FetchService::fetch_with_options(request, get_default_options(), handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_server_json_with_error_message<CallbackResult, RB, Req>(
|
|
||||||
url: &str,
|
|
||||||
request: RB,
|
|
||||||
callback: Callback<Result<CallbackResult>>,
|
|
||||||
error_message: &'static str,
|
|
||||||
) -> Result<FetchTask>
|
|
||||||
where
|
|
||||||
CallbackResult: serde::de::DeserializeOwned + 'static,
|
|
||||||
RB: Into<RequestBody<Req>>,
|
|
||||||
Req: Into<yew::format::Text>,
|
|
||||||
{
|
|
||||||
call_server(url, request, callback, error_message, |data: String| {
|
|
||||||
serde_json::from_str(&data).context("Could not parse response")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_server_empty_response_with_error_message<RB, Req>(
|
|
||||||
url: &str,
|
|
||||||
request: RB,
|
|
||||||
callback: Callback<Result<()>>,
|
|
||||||
error_message: &'static str,
|
|
||||||
) -> Result<FetchTask>
|
|
||||||
where
|
|
||||||
RB: Into<RequestBody<Req>>,
|
|
||||||
Req: Into<yew::format::Text>,
|
|
||||||
{
|
|
||||||
call_server(
|
|
||||||
url,
|
|
||||||
request,
|
|
||||||
callback,
|
|
||||||
error_message,
|
error_message,
|
||||||
|_data: String| Ok(()),
|
response.status(),
|
||||||
)
|
response.status_text(),
|
||||||
|
response.text().await?
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||||
|
url: &str,
|
||||||
|
request: Option<Body>,
|
||||||
|
error_message: &'static str,
|
||||||
|
) -> Result<CallbackResult>
|
||||||
|
where
|
||||||
|
CallbackResult: DeserializeOwned + 'static,
|
||||||
|
{
|
||||||
|
let data = call_server(url, request, error_message).await?;
|
||||||
|
serde_json::from_str(&data).context("Could not parse response")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||||
|
url: &str,
|
||||||
|
request: Option<Body>,
|
||||||
|
error_message: &'static str,
|
||||||
|
) -> Result<()> {
|
||||||
|
call_server(url, request, error_message).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, bool)> {
|
||||||
|
let jwt_claims = get_claims_from_jwt(response.token.as_str()).context("Could not parse JWT")?;
|
||||||
|
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||||
|
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||||
|
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||||
|
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||||
|
.context("Error setting cookie")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostService {
|
impl HostService {
|
||||||
pub fn graphql_query<QueryType>(
|
pub async fn graphql_query<QueryType>(
|
||||||
variables: QueryType::Variables,
|
variables: QueryType::Variables,
|
||||||
callback: Callback<Result<QueryType::ResponseData>>,
|
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<FetchTask>
|
) -> Result<QueryType::ResponseData>
|
||||||
where
|
where
|
||||||
QueryType: GraphQLQuery + 'static,
|
QueryType: GraphQLQuery + 'static,
|
||||||
{
|
{
|
||||||
|
@ -147,143 +95,103 @@ impl HostService {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let parse_graphql_response = move |data: String| {
|
|
||||||
serde_json::from_str(&data)
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(unwrap_graphql_response)
|
|
||||||
};
|
|
||||||
let request_body = QueryType::build_query(variables);
|
let request_body = QueryType::build_query(variables);
|
||||||
call_server(
|
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||||
"/api/graphql",
|
"/api/graphql",
|
||||||
&request_body,
|
Some(request_body),
|
||||||
callback,
|
|
||||||
error_message,
|
error_message,
|
||||||
parse_graphql_response,
|
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
.and_then(unwrap_graphql_response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login_start(
|
pub async fn login_start(
|
||||||
request: login::ClientLoginStartRequest,
|
request: login::ClientLoginStartRequest,
|
||||||
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>,
|
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
"/auth/opaque/login/start",
|
"/auth/opaque/login/start",
|
||||||
&request,
|
Some(request),
|
||||||
callback,
|
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login_finish(
|
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||||
request: login::ClientLoginFinishRequest,
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
callback: Callback<Result<(String, bool)>>,
|
|
||||||
) -> Result<FetchTask> {
|
|
||||||
let set_cookies = |jwt_claims: JWTClaims| {
|
|
||||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
|
||||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
|
||||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
|
||||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
|
||||||
.context("Error clearing cookie")
|
|
||||||
};
|
|
||||||
let parse_token = move |data: String| {
|
|
||||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(|r| {
|
|
||||||
get_claims_from_jwt(r.token.as_str())
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(set_cookies)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
call_server(
|
|
||||||
"/auth/opaque/login/finish",
|
"/auth/opaque/login/finish",
|
||||||
&request,
|
Some(request),
|
||||||
callback,
|
|
||||||
"Could not finish authentication",
|
"Could not finish authentication",
|
||||||
parse_token,
|
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
.and_then(set_cookies_from_jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_start(
|
pub async fn register_start(
|
||||||
request: registration::ClientRegistrationStartRequest,
|
request: registration::ClientRegistrationStartRequest,
|
||||||
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
|
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
"/auth/opaque/register/start",
|
"/auth/opaque/register/start",
|
||||||
&request,
|
Some(request),
|
||||||
callback,
|
|
||||||
"Could not start registration: ",
|
"Could not start registration: ",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_finish(
|
pub async fn register_finish(
|
||||||
request: registration::ClientRegistrationFinishRequest,
|
request: registration::ClientRegistrationFinishRequest,
|
||||||
callback: Callback<Result<()>>,
|
) -> Result<()> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
"/auth/opaque/register/finish",
|
"/auth/opaque/register/finish",
|
||||||
&request,
|
Some(request),
|
||||||
callback,
|
|
||||||
"Could not finish registration",
|
"Could not finish registration",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
|
pub async fn refresh() -> Result<(String, bool)> {
|
||||||
let set_cookies = |jwt_claims: JWTClaims| {
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
|
||||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
|
||||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
|
||||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
|
||||||
.context("Error clearing cookie")
|
|
||||||
};
|
|
||||||
let parse_token = move |data: String| {
|
|
||||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(|r| {
|
|
||||||
get_claims_from_jwt(r.token.as_str())
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(set_cookies)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
call_server(
|
|
||||||
"/auth/refresh",
|
"/auth/refresh",
|
||||||
yew::format::Nothing,
|
NO_BODY,
|
||||||
callback,
|
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
parse_token,
|
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
.and_then(set_cookies_from_jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The `_request` parameter is to make it the same shape as the other functions.
|
// The `_request` parameter is to make it the same shape as the other functions.
|
||||||
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
|
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")
|
||||||
"/auth/logout",
|
.await
|
||||||
yew::format::Nothing,
|
|
||||||
callback,
|
|
||||||
"Could not logout",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_password_step1(
|
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||||
username: &str,
|
|
||||||
callback: Callback<Result<()>>,
|
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
&format!("/auth/reset/step1/{}", url_escape::encode_query(username)),
|
&format!("/auth/reset/step1/{}", url_escape::encode_query(&username)),
|
||||||
yew::format::Nothing,
|
NO_BODY,
|
||||||
callback,
|
|
||||||
"Could not initiate password reset",
|
"Could not initiate password reset",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_password_step2(
|
pub async fn reset_password_step2(
|
||||||
token: &str,
|
token: String,
|
||||||
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
|
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&format!("/auth/reset/step2/{}", token),
|
&format!("/auth/reset/step2/{}", token),
|
||||||
yew::format::Nothing,
|
NO_BODY,
|
||||||
callback,
|
|
||||||
"Could not validate token",
|
"Could not validate token",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn probe_password_reset() -> Result<bool> {
|
||||||
|
Ok(
|
||||||
|
gloo_net::http::Request::get("/auth/reset/step1/lldap_unlikely_very_long_user_name")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status()
|
||||||
|
!= http::StatusCode::NOT_FOUND,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,88 +21,62 @@
|
||||||
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
||||||
//! take care of error and task handling.
|
//! take care of error and task handling.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
future::Future,
|
||||||
|
marker::PhantomData,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::infra::api::HostService;
|
use crate::infra::api::HostService;
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
|
use gloo_console::error;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::{
|
use yew::prelude::*;
|
||||||
prelude::*,
|
|
||||||
services::{
|
|
||||||
fetch::FetchTask,
|
|
||||||
reader::{FileData, ReaderService, ReaderTask},
|
|
||||||
ConsoleService,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
/// Trait required for common components.
|
/// Trait required for common components.
|
||||||
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||||
/// Handle the incoming message. If an error is returned here, any running task will be
|
/// Handle the incoming message. If an error is returned here, any running task will be
|
||||||
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
||||||
/// component will be refreshed.
|
/// component will be refreshed.
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool>;
|
||||||
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
||||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AnyTask {
|
|
||||||
None,
|
|
||||||
FetchTask(FetchTask),
|
|
||||||
ReaderTask(ReaderTask),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AnyTask {
|
|
||||||
fn is_some(&self) -> bool {
|
|
||||||
!matches!(self, AnyTask::None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Option<FetchTask>> for AnyTask {
|
|
||||||
fn from(task: Option<FetchTask>) -> Self {
|
|
||||||
match task {
|
|
||||||
Some(t) => AnyTask::FetchTask(t),
|
|
||||||
None => AnyTask::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Structure that contains the common parts needed by most components.
|
/// Structure that contains the common parts needed by most components.
|
||||||
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
||||||
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
||||||
link: ComponentLink<C>,
|
|
||||||
pub props: <C as Component>::Properties,
|
|
||||||
pub error: Option<Error>,
|
pub error: Option<Error>,
|
||||||
task: AnyTask,
|
is_task_running: Arc<Mutex<bool>>,
|
||||||
|
_phantom: PhantomData<C>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||||
|
pub fn create() -> Self {
|
||||||
|
CommonComponentParts {
|
||||||
|
error: None,
|
||||||
|
is_task_running: Arc::new(Mutex::new(false)),
|
||||||
|
_phantom: PhantomData::<C>,
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Whether there is a currently running task in the background.
|
/// Whether there is a currently running task in the background.
|
||||||
pub fn is_task_running(&self) -> bool {
|
pub fn is_task_running(&self) -> bool {
|
||||||
self.task.is_some()
|
*self.is_task_running.lock().unwrap()
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel any background task.
|
|
||||||
pub fn cancel_task(&mut self) {
|
|
||||||
self.task = AnyTask::None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
|
||||||
Self {
|
|
||||||
link,
|
|
||||||
props,
|
|
||||||
error: None,
|
|
||||||
task: AnyTask::None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
||||||
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
||||||
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
|
pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
|
||||||
com.mut_common().error = None;
|
com.mut_common().error = None;
|
||||||
match com.handle_msg(msg) {
|
match com.handle_msg(ctx, msg) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
ConsoleService::error(&e.to_string());
|
error!(&e.to_string());
|
||||||
com.mut_common().error = Some(e);
|
com.mut_common().error = Some(e);
|
||||||
com.mut_common().cancel_task();
|
assert!(!*com.mut_common().is_task_running.lock().unwrap());
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
|
@ -112,10 +86,11 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||||
/// Same as above, but the resulting error is instead passed to the reporting function.
|
/// Same as above, but the resulting error is instead passed to the reporting function.
|
||||||
pub fn update_and_report_error(
|
pub fn update_and_report_error(
|
||||||
com: &mut C,
|
com: &mut C,
|
||||||
|
ctx: &Context<C>,
|
||||||
msg: <C as Component>::Message,
|
msg: <C as Component>::Message,
|
||||||
report_fn: Callback<Error>,
|
report_fn: Callback<Error>,
|
||||||
) -> ShouldRender {
|
) -> bool {
|
||||||
let should_render = Self::update(com, msg);
|
let should_render = Self::update(com, ctx, msg);
|
||||||
com.mut_common()
|
com.mut_common()
|
||||||
.error
|
.error
|
||||||
.take()
|
.take()
|
||||||
|
@ -126,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||||
.unwrap_or(should_render)
|
.unwrap_or(should_render)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
|
|
||||||
/// properties have changed and return whether the component should update.
|
|
||||||
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
|
|
||||||
where
|
|
||||||
<C as yew::Component>::Properties: std::cmp::PartialEq,
|
|
||||||
{
|
|
||||||
self.props.neq_assign(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a callback from the link.
|
|
||||||
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
|
|
||||||
where
|
|
||||||
M: Into<C::Message>,
|
|
||||||
F: Fn(IN) -> M + 'static,
|
|
||||||
{
|
|
||||||
self.link.callback(function)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
||||||
/// result. Returns whether _starting the call_ failed.
|
/// result.
|
||||||
pub fn call_backend<M, Req, Cb, Resp>(
|
pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
|
||||||
&mut self,
|
|
||||||
method: M,
|
|
||||||
req: Req,
|
|
||||||
callback: Cb,
|
|
||||||
) -> Result<()>
|
|
||||||
where
|
where
|
||||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
Fut: Future<Output = Resp> + 'static,
|
||||||
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
||||||
{
|
{
|
||||||
self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?);
|
{
|
||||||
Ok(())
|
let mut running = self.is_task_running.lock().unwrap();
|
||||||
|
assert!(!*running);
|
||||||
|
*running = true;
|
||||||
|
}
|
||||||
|
let is_task_running = self.is_task_running.clone();
|
||||||
|
ctx.link().send_future(async move {
|
||||||
|
let res = fut.await;
|
||||||
|
*is_task_running.lock().unwrap() = false;
|
||||||
|
callback(res)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call the backend with a GraphQL query.
|
/// Call the backend with a GraphQL query.
|
||||||
|
@ -165,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||||
/// `EnumCallback` should usually be left as `_`.
|
/// `EnumCallback` should usually be left as `_`.
|
||||||
pub fn call_graphql<QueryType, EnumCallback>(
|
pub fn call_graphql<QueryType, EnumCallback>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
ctx: &Context<C>,
|
||||||
variables: QueryType::Variables,
|
variables: QueryType::Variables,
|
||||||
enum_callback: EnumCallback,
|
enum_callback: EnumCallback,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
|
@ -172,41 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||||
QueryType: GraphQLQuery + 'static,
|
QueryType: GraphQLQuery + 'static,
|
||||||
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
||||||
{
|
{
|
||||||
self.task = HostService::graphql_query::<QueryType>(
|
self.call_backend(
|
||||||
variables,
|
ctx,
|
||||||
self.link.callback(enum_callback),
|
HostService::graphql_query::<QueryType>(variables, error_message),
|
||||||
error_message,
|
enum_callback,
|
||||||
)
|
);
|
||||||
.map_err::<(), _>(|e| {
|
|
||||||
ConsoleService::log(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
.into();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
|
|
||||||
where
|
|
||||||
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
|
|
||||||
{
|
|
||||||
self.task = AnyTask::ReaderTask(ReaderService::read_file(
|
|
||||||
file,
|
|
||||||
self.link.callback_once(callback),
|
|
||||||
)?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
|
|
||||||
type Target = <C as Component>::Properties;
|
|
||||||
|
|
||||||
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
|
|
||||||
&self.props
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
|
|
||||||
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
|
|
||||||
&mut self.props
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
#[wasm_bindgen(module = "bootstrap")]
|
|
||||||
extern "C" {
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||||
pub type Modal;
|
pub type Modal;
|
||||||
|
|
||||||
#[wasm_bindgen(constructor)]
|
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||||
pub fn new(e: web_sys::Element) -> Modal;
|
pub fn new(e: web_sys::Element) -> Modal;
|
||||||
|
|
||||||
#[wasm_bindgen(method)]
|
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||||
pub fn show(this: &Modal);
|
pub fn show(this: &Modal);
|
||||||
|
|
||||||
#[wasm_bindgen(method)]
|
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||||
pub fn hide(this: &Modal);
|
pub fn hide(this: &Modal);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
#![forbid(non_ascii_idents)]
|
#![forbid(non_ascii_idents)]
|
||||||
#![allow(clippy::nonstandard_macro_braces)]
|
#![allow(clippy::uninlined_format_args)]
|
||||||
|
#![allow(clippy::let_unit_value)]
|
||||||
|
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
|
|
||||||
|
@ -8,7 +10,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn run_app() -> Result<(), JsValue> {
|
pub fn run_app() -> Result<(), JsValue> {
|
||||||
yew::start_app::<components::app::App>();
|
yew::start_app::<components::app::AppContainer>();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css
|
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css
|
||||||
|
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js
|
||||||
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
|
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
|
||||||
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
|
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
|
||||||
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
|
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
|
|
@ -1,4 +1,4 @@
|
||||||
import init, { run_app } from './pkg/lldap_app.js';
|
import init, { run_app } from '/pkg/lldap_app.js';
|
||||||
async function main() {
|
async function main() {
|
||||||
await init('/pkg/lldap_app_bg.wasm');
|
await init('/pkg/lldap_app_bg.wasm');
|
||||||
run_app();
|
run_app();
|
|
@ -10,3 +10,23 @@ header h2 {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark .bg-light {
|
||||||
|
background-color: rgba(59,59,59,1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark a {
|
||||||
|
color: #e1e1e1
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #212529
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .nav-link {
|
||||||
|
color: #e1e1e1
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #212529
|
||||||
|
}
|
BIN
docs/cookie.png
Normal file
BIN
docs/cookie.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 61 KiB |
91
docs/database_migration.md
Normal file
91
docs/database_migration.md
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
# Migration
|
||||||
|
|
||||||
|
Existing servers can migrate from one database backend to another. This page includes guidance for migrating from SQLite - similar concepts apply when migrating from databases of other types.
|
||||||
|
|
||||||
|
NOTE: [pgloader](https://github.com/dimitri/pgloader) is a tool that can easily migrate to PostgreSQL from other databases. Consider it if your target database is PostgreSQL
|
||||||
|
|
||||||
|
The process is as follows:
|
||||||
|
|
||||||
|
1. Create empty schema on target database
|
||||||
|
2. Stop/pause LLDAP and dump existing values
|
||||||
|
3. Sanitize for target DB (not always required)
|
||||||
|
4. Insert data into target
|
||||||
|
5. Change LLDAP config to new target and restart
|
||||||
|
|
||||||
|
The steps below assume you already have PostgreSQL or MySQL set up with an empty database for LLDAP to use.
|
||||||
|
|
||||||
|
## Create schema on target
|
||||||
|
|
||||||
|
LLDAP has a command that will connect to a target database and initialize the
|
||||||
|
schema. If running with docker, run the following command to use your active
|
||||||
|
instance (this has the benefit of ensuring your container has access):
|
||||||
|
|
||||||
|
```
|
||||||
|
docker exec -it <LLDAP container name> /app/lldap create_schema -d <Target database url>
|
||||||
|
```
|
||||||
|
|
||||||
|
If it succeeds, you can proceed to the next step.
|
||||||
|
|
||||||
|
## Create a dump of existing data
|
||||||
|
|
||||||
|
We want to dump (almost) all existing values to some file - the exception being the `metadata` table. Be sure to stop/pause LLDAP during this step, as some
|
||||||
|
databases (SQLite in this example) will give an error if LLDAP is in the middle of a write. The dump should consist just INSERT
|
||||||
|
statements. There are various ways to do this, but a simple enough way is filtering a
|
||||||
|
whole database dump. For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
sqlite3 /path/to/lldap/config/users.db .dump | grep "^INSERT" | grep -v "^INSERT INTO metadata" > /path/to/dump.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sanitize data
|
||||||
|
|
||||||
|
Some databases might use different formats for some data - for example, PostgreSQL uses
|
||||||
|
a different syntax for hex strings than SQLite. We also want to make sure inserts are done in
|
||||||
|
a transaction in case one of the statements fail.
|
||||||
|
|
||||||
|
### To PostgreSQL
|
||||||
|
|
||||||
|
PostgreSQL uses a different hex string format. The command below should switch SQLite
|
||||||
|
format to PostgreSQL format, and wrap it all in a transaction:
|
||||||
|
|
||||||
|
```
|
||||||
|
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" \
|
||||||
|
-e '1s/^/BEGIN;\n/' \
|
||||||
|
-e '$aCOMMIT;' /path/to/dump.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### To MySQL
|
||||||
|
|
||||||
|
MySQL mostly cooperates, but it gets some errors if you don't escape the `groups` table. Run the
|
||||||
|
following command to wrap all table names in backticks for good measure, and wrap the inserts in
|
||||||
|
a transaction:
|
||||||
|
|
||||||
|
```
|
||||||
|
sed -i -r -e 's/^INSERT INTO ([a-zA-Z0-9_]+) /INSERT INTO `\1` /' \
|
||||||
|
-e '1s/^/START TRANSACTION;\n/' \
|
||||||
|
-e '$aCOMMIT;' /path/to/dump.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Insert data
|
||||||
|
|
||||||
|
Insert the data generated from the previous step into the target database. If you encounter errors,
|
||||||
|
you may need to manually tweak your dump, or make changed in LLDAP and recreate the dump.
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
`psql -d <database> -U <username> -W < /path/to/dump.sql`
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
`psql -d <database> -U <username> -W -f /path/to/dump.sql`
|
||||||
|
|
||||||
|
### MySQL
|
||||||
|
|
||||||
|
`mysql -u <username> -p <database> < /path/to/dump.sql`
|
||||||
|
|
||||||
|
|
||||||
|
## Switch to new database
|
||||||
|
|
||||||
|
Modify your `database_url` in `lldap_config.toml` (or `LLDAP_DATABASE_URL` in the env)
|
||||||
|
to point to your new database (the same value used when generating schema). Restart
|
||||||
|
LLDAP and check the logs to ensure there were no errors.
|
90
docs/scripting.md
Normal file
90
docs/scripting.md
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
# Scripting
|
||||||
|
|
||||||
|
Programmatically accessing LLDAP can be done either through the LDAP protocol,
|
||||||
|
or via the GraphQL API.
|
||||||
|
|
||||||
|
## LDAP
|
||||||
|
|
||||||
|
Most _read-only_ queries about users and groups are supported. Anything not
|
||||||
|
supported would be considered a missing feature or a bug.
|
||||||
|
|
||||||
|
Most _modification_ queries are not supported, except for creating users and
|
||||||
|
changing the password (through the extended password operation). Those could be
|
||||||
|
added in the future, on a case-by-case basis.
|
||||||
|
|
||||||
|
Most _meta_-queries about the LDAP server itself are not supported and are out
|
||||||
|
of scope. That includes anything that touches the schema, for instance. LLDAP
|
||||||
|
still supports basic RootDSE queries.
|
||||||
|
|
||||||
|
Anonymous bind is not supported.
|
||||||
|
|
||||||
|
## GraphQL
|
||||||
|
|
||||||
|
The best way to interact with LLDAP programmatically is via the GraphQL
|
||||||
|
interface. You can use any language that has a GraphQL library (most of them
|
||||||
|
do), and use the [GraphQL Schema](../schema.graphql) to guide your queries.
|
||||||
|
|
||||||
|
### Getting a token
|
||||||
|
|
||||||
|
You'll need a JWT (authentication token) to issue GraphQL queries. Your view of
|
||||||
|
the system will be limited by the rights of your user. In particular, regular
|
||||||
|
users can only see themselves and the groups they belong to (but not other
|
||||||
|
members of these groups, for instance).
|
||||||
|
|
||||||
|
#### Manually
|
||||||
|
|
||||||
|
Log in to the web front-end of LLDAP. Then open the developer tools (F12), find
|
||||||
|
the "Storage > Cookies", and you'll find the "token" cookie with your JWT.
|
||||||
|
|
||||||
|
![Cookies menu with a JWT](cookie.png)
|
||||||
|
|
||||||
|
#### Automatically
|
||||||
|
|
||||||
|
The easiest way is to send a json POST request to `/auth/simple/login` with
|
||||||
|
`{"username": "john", "password": "1234"}` in the body.
|
||||||
|
Then you'll receive a JSON response with:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"token": "eYbat...",
|
||||||
|
"refreshToken": "3bCka...",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the token
|
||||||
|
|
||||||
|
You can use the token directly, either as a cookie, or as a bearer auth token
|
||||||
|
(add an "Authorization" header with contents `"Bearer <token>"`).
|
||||||
|
|
||||||
|
The JWT is valid for 1 day (unless you log out explicitly).
|
||||||
|
You can use the refresh token to query `/auth/refresh` and get another JWT. The
|
||||||
|
refresh token is valid for 30 days.
|
||||||
|
|
||||||
|
### Testing your GraphQL queries
|
||||||
|
|
||||||
|
You can go to `/api/graphql/playground` to test your queries and explore the
|
||||||
|
data in the playground. You'll need to provide the JWT in the headers:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "Authorization": "Bearer abcdef123..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Then you can enter your query, for instance:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
{
|
||||||
|
user(userId:"admin") {
|
||||||
|
displayName
|
||||||
|
}
|
||||||
|
groups {
|
||||||
|
id
|
||||||
|
displayName
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The schema is on the right, along with some basic docs.
|
105
example_configs/authentik.md
Normal file
105
example_configs/authentik.md
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
# Name
|
||||||
|
```
|
||||||
|
lldap
|
||||||
|
```
|
||||||
|
|
||||||
|
# Slug
|
||||||
|
```
|
||||||
|
lldap
|
||||||
|
```
|
||||||
|
- [x] Enabled
|
||||||
|
- [x] Sync Users
|
||||||
|
- [x] User password writeback
|
||||||
|
- [x] Sync groups
|
||||||
|
|
||||||
|
# Connection settings
|
||||||
|
|
||||||
|
## Server URI
|
||||||
|
```
|
||||||
|
ldap://lldap:3890
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Enable StartTLS
|
||||||
|
|
||||||
|
## TLS Verification Certificate
|
||||||
|
```
|
||||||
|
---------
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bind CN
|
||||||
|
```
|
||||||
|
uid=admin,ou=people,dc=example,dc=com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bind Password
|
||||||
|
```
|
||||||
|
ADMIN_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Base DN
|
||||||
|
```
|
||||||
|
dc=example,dc=com
|
||||||
|
```
|
||||||
|
|
||||||
|
# LDAP Attribute mapping
|
||||||
|
## User Property Mappings
|
||||||
|
- [x] authentik default LDAP Mapping: mail
|
||||||
|
- [x] authentik default LDAP Mapping: Name
|
||||||
|
- [x] authentik default Active Directory Mapping: givenName
|
||||||
|
- [ ] authentik default Active Directory Mapping: sAMAccountName
|
||||||
|
- [x] authentik default Active Directory Mapping: sn
|
||||||
|
- [ ] authentik default Active Directory Mapping: userPrincipalName
|
||||||
|
- [x] authentik default OpenLDAP Mapping: cn
|
||||||
|
- [x] authentik default OpenLDAP Mapping: uid
|
||||||
|
|
||||||
|
## Group Property Mappings
|
||||||
|
- [ ] authentik default LDAP Mapping: mail
|
||||||
|
- [ ] authentik default LDAP Mapping: Name
|
||||||
|
- [ ] authentik default Active Directory Mapping: givenName
|
||||||
|
- [ ] authentik default Active Directory Mapping: sAMAccountName
|
||||||
|
- [ ] authentik default Active Directory Mapping: sn
|
||||||
|
- [ ] authentik default Active Directory Mapping: userPrincipalName
|
||||||
|
- [x] authentik default OpenLDAP Mapping: cn
|
||||||
|
- [ ] authentik default OpenLDAP Mapping: uid
|
||||||
|
|
||||||
|
# Additional settings
|
||||||
|
|
||||||
|
## Group
|
||||||
|
```
|
||||||
|
---------
|
||||||
|
```
|
||||||
|
|
||||||
|
## User path
|
||||||
|
```
|
||||||
|
LDAP/users
|
||||||
|
```
|
||||||
|
|
||||||
|
## Addition User DN
|
||||||
|
```
|
||||||
|
ou=people
|
||||||
|
```
|
||||||
|
|
||||||
|
## Addition Group DN
|
||||||
|
```
|
||||||
|
ou=groups
|
||||||
|
```
|
||||||
|
|
||||||
|
## User object filter
|
||||||
|
```
|
||||||
|
(objectClass=person)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Group object filter
|
||||||
|
```
|
||||||
|
(objectClass=groupOfUniqueNames)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Group membership field
|
||||||
|
```
|
||||||
|
member
|
||||||
|
```
|
||||||
|
|
||||||
|
## Object uniqueness field
|
||||||
|
```
|
||||||
|
uid
|
||||||
|
```
|
32
example_configs/dex_config.yml
Normal file
32
example_configs/dex_config.yml
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# lldap configuration:
|
||||||
|
# LLDAP_LDAP_BASE_DN: dc=example,dc=com
|
||||||
|
|
||||||
|
# ##############################
|
||||||
|
# rest of the Dex options
|
||||||
|
# ##############################
|
||||||
|
|
||||||
|
connectors:
|
||||||
|
- type: ldap
|
||||||
|
id: ldap
|
||||||
|
name: LDAP
|
||||||
|
config:
|
||||||
|
host: lldap-host # make sure it does not start with `ldap://`
|
||||||
|
port: 3890 # or 6360 if you have ldaps enabled
|
||||||
|
insecureNoSSL: true # or false if you have ldaps enabled
|
||||||
|
insecureSkipVerify: true # or false if you have ldaps enabled
|
||||||
|
bindDN: uid=admin,ou=people,dc=example,dc=com # replace admin with your admin user
|
||||||
|
bindPW: very-secure-password # replace with your admin password
|
||||||
|
userSearch:
|
||||||
|
baseDN: ou=people,dc=example,dc=com
|
||||||
|
username: uid
|
||||||
|
idAttr: uid
|
||||||
|
emailAttr: mail
|
||||||
|
nameAttr: displayName
|
||||||
|
preferredUsernameAttr: uid
|
||||||
|
groupSearch:
|
||||||
|
baseDN: ou=groups,dc=example,dc=com
|
||||||
|
filter: "(objectClass=groupOfUniqueNames)"
|
||||||
|
userMatchers:
|
||||||
|
- userAttr: DN
|
||||||
|
groupAttr: member
|
||||||
|
nameAttr: cn
|
|
@ -1,4 +1,4 @@
|
||||||
# Configuration for Gitea
|
# Configuration for Gitea (& Forgejo)
|
||||||
In Gitea, go to `Site Administration > Authentication Sources` and click `Add Authentication Source`
|
In Gitea, go to `Site Administration > Authentication Sources` and click `Add Authentication Source`
|
||||||
Select `LDAP (via BindDN)`
|
Select `LDAP (via BindDN)`
|
||||||
|
|
||||||
|
@ -14,9 +14,36 @@ To log in they can either use their email address or user name. If you only want
|
||||||
For more info on the user filter, see: https://docs.gitea.io/en-us/authentication/#ldap-via-binddn
|
For more info on the user filter, see: https://docs.gitea.io/en-us/authentication/#ldap-via-binddn
|
||||||
* Admin Filter: Use `(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)` if you want lldap admins to become Gitea admins. Leave empty otherwise.
|
* Admin Filter: Use `(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)` if you want lldap admins to become Gitea admins. Leave empty otherwise.
|
||||||
* Username Attribute: `uid`
|
* Username Attribute: `uid`
|
||||||
|
* First Name Attribute: `givenName`
|
||||||
|
* Surname Attribute: `sn`
|
||||||
* Email Attribute: `mail`
|
* Email Attribute: `mail`
|
||||||
|
* Avatar Attribute: `jpegPhoto`
|
||||||
* Check `Enable User Synchronization`
|
* Check `Enable User Synchronization`
|
||||||
|
|
||||||
Replace every instance of `dc=example,dc=com` with your configured domain.
|
Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||||
|
|
||||||
After applying the above settings, users should be able to log in with either their user name or email address.
|
After applying the above settings, users should be able to log in with either their user name or email address.
|
||||||
|
|
||||||
|
## Syncronizing LDAP groups with existing teams in organisations
|
||||||
|
|
||||||
|
Groups in LLDAP can be syncronized with teams in organisations. Organisations and teams must be created manually in Gitea.
|
||||||
|
It is possible to syncronize one LDAP group with multiple teams in a Gitea organization.
|
||||||
|
|
||||||
|
Check `Enable LDAP Groups`
|
||||||
|
|
||||||
|
* Group Search Base DN: `ou=groups,dc=example,dc=com`
|
||||||
|
* Group Attribute Containing List Of Users: `member`
|
||||||
|
* User Attribute Listed In Group: `dn`
|
||||||
|
* Map LDAP groups to Organization teams: `{"cn=Groupname1,ou=groups,dc=example,dc=com":{"Organization1": ["Teamname"]},"cn=Groupname2,ou=groups,dc=example,dc=com": {"Organization2": ["Teamname1", "Teamname2"]}}`
|
||||||
|
|
||||||
|
Check `Remove Users from syncronised teams...`
|
||||||
|
|
||||||
|
The `Map LDAP groups to Organization teams` config is JSON formatted and can be extended to as many groups as needed.
|
||||||
|
|
||||||
|
Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||||
|
|
||||||
|
# Configuration for Gitea in `simple auth` mode
|
||||||
|
|
||||||
|
* The configuration method is the same as `BindDN` mode.
|
||||||
|
* `BindDN` and `password` are not required
|
||||||
|
* Gitea will not be able to pre-sync users, user account will be created at login time.
|
||||||
|
|
BIN
example_configs/images/rancher_ldap_config.png
Normal file
BIN
example_configs/images/rancher_ldap_config.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 148 KiB |
|
@ -35,6 +35,12 @@ Otherwise, just use:
|
||||||
```
|
```
|
||||||
(uid=*)
|
(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
|
||||||
|
|
||||||
|
|
56
example_configs/nexus.md
Normal file
56
example_configs/nexus.md
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
# Configuration for Sonatype Nexus Repository Manager 3
|
||||||
|
In Nexus log in as an administrator, go to `Server Administration and configuration (gear icon)`
|
||||||
|
|
||||||
|
Select `LDAP` under the `Security` section
|
||||||
|
|
||||||
|
Click `Create connection`
|
||||||
|
|
||||||
|
* Host: A name for the connection e.g. lldap
|
||||||
|
* Type: ldap
|
||||||
|
* Host: Your lldap server's ip/hostname
|
||||||
|
* Port: Your lldap server's port (3890 by default)
|
||||||
|
* Base DN: `dc=example,dc=com`
|
||||||
|
* Authentication Method: Simple Authentication
|
||||||
|
* Username or DN: `uid=admin,ou=people,dc=example,dc=com` or preferably create a read only user in lldap with the lldap_strict_readonly group.
|
||||||
|
* Password: The password for the user specified above
|
||||||
|
|
||||||
|
Click `Verify connection` if successful click `Next`
|
||||||
|
|
||||||
|
* Select a template: Generic ldap server
|
||||||
|
* User Relative DN: `ou=people`
|
||||||
|
* User subtree: Leave unchecked
|
||||||
|
* Object class: person
|
||||||
|
* User Filter: Leave empty to allow all users to log in or `(memberOf=uid=nexus_users,ou=groups,dc=example,dc=com)` for a specific group
|
||||||
|
* Username Attribute: `uid`
|
||||||
|
* Real Name Attribute: `cn`
|
||||||
|
* Email Attribute: `mail`
|
||||||
|
* Password Attribute: Leave blank
|
||||||
|
* Check `Enable User Synchronization`
|
||||||
|
|
||||||
|
Test user login credentials with `Verify login`
|
||||||
|
|
||||||
|
## Set up group mapping as roles
|
||||||
|
|
||||||
|
Check `Map LDAP groups as roles`
|
||||||
|
|
||||||
|
* Group Type: `Static Groups`
|
||||||
|
* Group relative DN: `ou=groups`
|
||||||
|
* Group subtree: Leave unchecked
|
||||||
|
* Group object class: `groupOfUniqueNames`
|
||||||
|
* Group ID attribute: `cn`
|
||||||
|
* Group member attribute: `member`
|
||||||
|
* Group member format: `uid=${username},ou=people,dc=example,dc=com`
|
||||||
|
|
||||||
|
Check user mapping with `Verify user mapping`
|
||||||
|
|
||||||
|
## Map specific roles to groups
|
||||||
|
In Nexus log in as an administrator, go to `Server Administration and configuration (gear icon)`
|
||||||
|
Select `Roles` under the `Security` section
|
||||||
|
|
||||||
|
Click `Create Role`
|
||||||
|
|
||||||
|
* Role ID: e.g. nexus_admin (name in nexus)
|
||||||
|
* Role Name: e.g. nexus_admin (group in lldap)
|
||||||
|
* Add privileges/roles as needed e.g. under Roles add nx-admin to the "contained" list
|
||||||
|
|
||||||
|
Click `Save`
|
95
example_configs/rancher.md
Normal file
95
example_configs/rancher.md
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
# Configuration for SUSE Rancher (any version)
|
||||||
|
### Left (hamburger) menu > Users & Authentication > OpenLDAP (yes, we are using the OpenLDAP config page)
|
||||||
|
---
|
||||||
|
|
||||||
|
## LDAP configuration
|
||||||
|
|
||||||
|
#### Hostname/IP
|
||||||
|
```
|
||||||
|
ip-address, DNS name or when running in Kubernetes (see https://github.com/Evantage-WS/lldap-kubernetes), lldap-service.lldap.svc.cluster.local
|
||||||
|
```
|
||||||
|
#### Port
|
||||||
|
```
|
||||||
|
3890
|
||||||
|
```
|
||||||
|
#### Service Account Distinguished name
|
||||||
|
A better option is to use a readonly account for accessing the LLDAP server
|
||||||
|
```
|
||||||
|
cn=admin,ou=people,dc=example,dc=com
|
||||||
|
```
|
||||||
|
#### Service Account Password
|
||||||
|
```
|
||||||
|
xxx
|
||||||
|
```
|
||||||
|
#### User Search Base
|
||||||
|
```
|
||||||
|
ou=people,dc=example,dc=com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Group Search Base
|
||||||
|
```
|
||||||
|
ou=groups,dc=example,dc=com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Object Class (users)
|
||||||
|
```
|
||||||
|
inetOrgPerson
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Object Class (groups)
|
||||||
|
```
|
||||||
|
groupOfUniqueNames
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Username Attribute
|
||||||
|
```
|
||||||
|
uid
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Name Attribute
|
||||||
|
```
|
||||||
|
cn
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Login Attribute
|
||||||
|
```
|
||||||
|
uid
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Group Member User Attribute
|
||||||
|
```
|
||||||
|
dn
|
||||||
|
```
|
||||||
|
|
||||||
|
#### User Member Attribute
|
||||||
|
```
|
||||||
|
memberOf
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Search Attribute (groups)
|
||||||
|
```
|
||||||
|
cn
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Search Attribute (users)
|
||||||
|
```
|
||||||
|
uid|sn|givenName
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Group Member Mapping Attribute
|
||||||
|
```
|
||||||
|
member
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Group DN Attribute
|
||||||
|
```
|
||||||
|
dn
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Choose "Search direct and nested group memberships"
|
||||||
|
|
||||||
|
##### Fill in the username and password of an admin user at Test and Enable Authentication and hit save
|
||||||
|
|
||||||
|
## Rancher OpenLDAP config page
|
||||||
|
|
||||||
|
![Rancher OpenLDAP config page](images/rancher_ldap_config.png)
|
64
example_configs/wikijs.md
Normal file
64
example_configs/wikijs.md
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
# Configuration for WikiJS
|
||||||
|
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||||
|
### LDAP URL
|
||||||
|
```
|
||||||
|
ldap://lldap:3890
|
||||||
|
```
|
||||||
|
### Admin Bind DN
|
||||||
|
```
|
||||||
|
uid=admin,ou=people,dc=example,dc=com
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```
|
||||||
|
uid=readonlyuser,ou=people,dc=example,dc=com
|
||||||
|
```
|
||||||
|
### Admin Bind Credentials
|
||||||
|
```
|
||||||
|
ADMINPASSWORD
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```
|
||||||
|
READONLYUSERPASSWORD
|
||||||
|
```
|
||||||
|
### Search Base
|
||||||
|
```
|
||||||
|
ou=people,dc=example,dc=com
|
||||||
|
```
|
||||||
|
### Search Filter
|
||||||
|
If you wish the permitted users to be restricted to just the `wiki` group:
|
||||||
|
```
|
||||||
|
(&(memberof=cn=wiki,ou=groups,dc=example,dc=com)(|(uid={{username}})(mail={{username}))(objectClass=person))
|
||||||
|
```
|
||||||
|
If you wish any of the registered LLDAP users to be permitted to use WikiJS:
|
||||||
|
```
|
||||||
|
(&(|(uid={{username}})(mail={{username}))(objectClass=person))
|
||||||
|
```
|
||||||
|
### Use TLS
|
||||||
|
Left toggled off
|
||||||
|
### Verify TLS Certificate
|
||||||
|
Left toggled off
|
||||||
|
### TLS Certificate Path
|
||||||
|
Left blank
|
||||||
|
### Unique ID Field Mapping
|
||||||
|
```
|
||||||
|
uid
|
||||||
|
```
|
||||||
|
### Email Field Mapping
|
||||||
|
```
|
||||||
|
mail
|
||||||
|
```
|
||||||
|
### Display Name Field Mapping
|
||||||
|
```
|
||||||
|
givenname
|
||||||
|
```
|
||||||
|
### Avatar Picture Field Mapping
|
||||||
|
```
|
||||||
|
jpegPhoto
|
||||||
|
```
|
||||||
|
### Allow self-registration
|
||||||
|
Toggled on
|
||||||
|
### Limit to specific email domains
|
||||||
|
Left blank
|
||||||
|
### Assign to group
|
||||||
|
I created a group called `users` and assign my LDAP users to that by default.
|
||||||
|
You can use the local admin account to login and promote an LDAP user to `admin` group if you wish and then deactivate the local login option
|
|
@ -75,16 +75,16 @@
|
||||||
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
|
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
|
||||||
|
|
||||||
## Database URL.
|
## Database URL.
|
||||||
## This encodes the type of database (SQlite, Mysql and so
|
## This encodes the type of database (SQlite, MySQL, or PostgreSQL)
|
||||||
## on), the path, the user, password, and sometimes the mode (when
|
## , the path, the user, password, and sometimes the mode (when
|
||||||
## relevant).
|
## relevant).
|
||||||
## Note: Currently, only SQlite is supported. SQlite should come with
|
## Note: SQlite should come with "?mode=rwc" to create the DB
|
||||||
## "?mode=rwc" to create the DB if not present.
|
## if not present.
|
||||||
## Example URLs:
|
## Example URLs:
|
||||||
## - "postgres://postgres-user:password@postgres-server/my-database"
|
## - "postgres://postgres-user:password@postgres-server/my-database"
|
||||||
## - "mysql://mysql-user:password@mysql-server/my-database"
|
## - "mysql://mysql-user:password@mysql-server/my-database"
|
||||||
##
|
##
|
||||||
## This can be overridden with the DATABASE_URL env variable.
|
## This can be overridden with the LLDAP_DATABASE_URL env variable.
|
||||||
database_url = "sqlite:///data/users.db?mode=rwc"
|
database_url = "sqlite:///data/users.db?mode=rwc"
|
||||||
|
|
||||||
## Private key file.
|
## Private key file.
|
||||||
|
@ -113,7 +113,7 @@ key_file = "/data/private_key"
|
||||||
#server="smtp.gmail.com"
|
#server="smtp.gmail.com"
|
||||||
## The SMTP port.
|
## The SMTP port.
|
||||||
#port=587
|
#port=587
|
||||||
## How the connection is encrypted, either "TLS" or "STARTTLS".
|
## How the connection is encrypted, either "NONE" (no encryption), "TLS" or "STARTTLS".
|
||||||
#smtp_encryption = "TLS"
|
#smtp_encryption = "TLS"
|
||||||
## The SMTP user, usually your email address.
|
## The SMTP user, usually your email address.
|
||||||
#user="sender@gmail.com"
|
#user="sender@gmail.com"
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
#![allow(clippy::uninlined_format_args)]
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
|
|
@ -5,47 +5,47 @@ name = "lldap"
|
||||||
version = "0.4.2-alpha"
|
version = "0.4.2-alpha"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix = "0.12"
|
actix = "0.13"
|
||||||
actix-files = "0.6.0-beta.6"
|
actix-files = "0.6"
|
||||||
actix-http = "=3.0.0-beta.9"
|
actix-http = "3"
|
||||||
actix-rt = "2.2.0"
|
actix-rt = "2"
|
||||||
actix-server = "=2.0.0-beta.5"
|
actix-server = "2"
|
||||||
actix-service = "2.0.0"
|
actix-service = "2"
|
||||||
actix-web = "=4.0.0-beta.8"
|
actix-web = "4.3"
|
||||||
actix-web-httpauth = "0.6.0-beta.2"
|
actix-web-httpauth = "0.8"
|
||||||
anyhow = "*"
|
anyhow = "*"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.13"
|
base64 = "*"
|
||||||
bincode = "1.3"
|
bincode = "1.3"
|
||||||
cron = "*"
|
cron = "*"
|
||||||
derive_builder = "0.10.2"
|
derive_builder = "0.12"
|
||||||
figment_file_provider_adapter = "0.1"
|
figment_file_provider_adapter = "0.1"
|
||||||
futures = "*"
|
futures = "*"
|
||||||
futures-util = "*"
|
futures-util = "*"
|
||||||
hmac = "0.10"
|
hmac = "0.12"
|
||||||
http = "*"
|
http = "*"
|
||||||
itertools = "0.10.1"
|
itertools = "0.10"
|
||||||
juniper = "0.15.10"
|
juniper = "0.15"
|
||||||
juniper_actix = "0.4.0"
|
jwt = "0.16"
|
||||||
jwt = "0.13"
|
lber = "0.4.1"
|
||||||
ldap3_proto = "*"
|
ldap3_proto = ">=0.3.1"
|
||||||
log = "*"
|
log = "*"
|
||||||
orion = "0.16"
|
orion = "0.17"
|
||||||
rustls = "0.20"
|
rustls = "0.20"
|
||||||
serde = "*"
|
serde = "*"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha2 = "0.9"
|
sha2 = "0.10"
|
||||||
thiserror = "*"
|
thiserror = "*"
|
||||||
time = "0.2"
|
time = "0.3"
|
||||||
tokio-rustls = "0.23"
|
tokio-rustls = "0.23"
|
||||||
tokio-stream = "*"
|
tokio-stream = "*"
|
||||||
tokio-util = "0.7.3"
|
tokio-util = "0.7"
|
||||||
tracing = "*"
|
tracing = "*"
|
||||||
tracing-actix-web = "0.4.0-beta.7"
|
tracing-actix-web = "0.7"
|
||||||
tracing-attributes = "^0.1.21"
|
tracing-attributes = "^0.1.21"
|
||||||
tracing-log = "*"
|
tracing-log = "*"
|
||||||
rustls-pemfile = "1.0.0"
|
rustls-pemfile = "1"
|
||||||
serde_bytes = "0.11.7"
|
serde_bytes = "0.11"
|
||||||
webpki-roots = "*"
|
webpki-roots = "*"
|
||||||
|
|
||||||
[dependencies.chrono]
|
[dependencies.chrono]
|
||||||
|
@ -54,7 +54,7 @@ version = "*"
|
||||||
|
|
||||||
[dependencies.clap]
|
[dependencies.clap]
|
||||||
features = ["std", "color", "suggestions", "derive", "env"]
|
features = ["std", "color", "suggestions", "derive", "env"]
|
||||||
version = "3.1.15"
|
version = "4"
|
||||||
|
|
||||||
[dependencies.figment]
|
[dependencies.figment]
|
||||||
features = ["env", "toml"]
|
features = ["env", "toml"]
|
||||||
|
@ -67,15 +67,11 @@ features = ["env-filter", "tracing-log"]
|
||||||
[dependencies.lettre]
|
[dependencies.lettre]
|
||||||
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
|
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
|
||||||
default-features = false
|
default-features = false
|
||||||
version = "0.10.0-rc.3"
|
version = "0.10.1"
|
||||||
|
|
||||||
[dependencies.lldap_auth]
|
[dependencies.lldap_auth]
|
||||||
path = "../auth"
|
path = "../auth"
|
||||||
|
|
||||||
[dependencies.sea-query]
|
|
||||||
version = "*"
|
|
||||||
features = ["with-chrono"]
|
|
||||||
|
|
||||||
[dependencies.opaque-ke]
|
[dependencies.opaque-ke]
|
||||||
version = "0.6"
|
version = "0.6"
|
||||||
|
|
||||||
|
@ -89,7 +85,7 @@ version = "*"
|
||||||
|
|
||||||
[dependencies.tokio]
|
[dependencies.tokio]
|
||||||
features = ["full"]
|
features = ["full"]
|
||||||
version = "1.17"
|
version = "1.25"
|
||||||
|
|
||||||
[dependencies.uuid]
|
[dependencies.uuid]
|
||||||
features = ["v3"]
|
features = ["v3"]
|
||||||
|
@ -101,7 +97,7 @@ version = "^0.1.4"
|
||||||
|
|
||||||
[dependencies.actix-tls]
|
[dependencies.actix-tls]
|
||||||
features = ["default", "rustls"]
|
features = ["default", "rustls"]
|
||||||
version = "=3.0.0-beta.5"
|
version = "3"
|
||||||
|
|
||||||
[dependencies.image]
|
[dependencies.image]
|
||||||
features = ["jpeg"]
|
features = ["jpeg"]
|
||||||
|
@ -109,7 +105,7 @@ default-features = false
|
||||||
version = "0.24"
|
version = "0.24"
|
||||||
|
|
||||||
[dependencies.sea-orm]
|
[dependencies.sea-orm]
|
||||||
version= "0.10.3"
|
version= "0.11"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"]
|
features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"]
|
||||||
|
|
||||||
|
@ -119,4 +115,4 @@ default-features = false
|
||||||
features = ["rustls-tls-webpki-roots"]
|
features = ["rustls-tls-webpki-roots"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
mockall = "0.9.1"
|
mockall = "0.11"
|
||||||
|
|
|
@ -14,31 +14,85 @@ pub struct BindRequest {
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct SubStringFilter {
|
||||||
|
pub initial: Option<String>,
|
||||||
|
pub any: Vec<String>,
|
||||||
|
pub final_: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubStringFilter {
|
||||||
|
pub fn to_sql_filter(&self) -> String {
|
||||||
|
let mut filter = String::with_capacity(
|
||||||
|
self.initial.as_ref().map(String::len).unwrap_or_default()
|
||||||
|
+ 1
|
||||||
|
+ self.any.iter().map(String::len).sum::<usize>()
|
||||||
|
+ self.any.len()
|
||||||
|
+ self.final_.as_ref().map(String::len).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
if let Some(f) = &self.initial {
|
||||||
|
filter.push_str(&f.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
filter.push('%');
|
||||||
|
for part in self.any.iter() {
|
||||||
|
filter.push_str(&part.to_ascii_lowercase());
|
||||||
|
filter.push('%');
|
||||||
|
}
|
||||||
|
if let Some(f) = &self.final_ {
|
||||||
|
filter.push_str(&f.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||||
pub enum UserRequestFilter {
|
pub enum UserRequestFilter {
|
||||||
And(Vec<UserRequestFilter>),
|
And(Vec<UserRequestFilter>),
|
||||||
Or(Vec<UserRequestFilter>),
|
Or(Vec<UserRequestFilter>),
|
||||||
Not(Box<UserRequestFilter>),
|
Not(Box<UserRequestFilter>),
|
||||||
UserId(UserId),
|
UserId(UserId),
|
||||||
|
UserIdSubString(SubStringFilter),
|
||||||
Equality(UserColumn, String),
|
Equality(UserColumn, String),
|
||||||
|
SubString(UserColumn, SubStringFilter),
|
||||||
// Check if a user belongs to a group identified by name.
|
// Check if a user belongs to a group identified by name.
|
||||||
MemberOf(String),
|
MemberOf(String),
|
||||||
// Same, by id.
|
// Same, by id.
|
||||||
MemberOfId(GroupId),
|
MemberOfId(GroupId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<bool> for UserRequestFilter {
|
||||||
|
fn from(val: bool) -> Self {
|
||||||
|
if val {
|
||||||
|
Self::And(vec![])
|
||||||
|
} else {
|
||||||
|
Self::Not(Box::new(Self::And(vec![])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||||
pub enum GroupRequestFilter {
|
pub enum GroupRequestFilter {
|
||||||
And(Vec<GroupRequestFilter>),
|
And(Vec<GroupRequestFilter>),
|
||||||
Or(Vec<GroupRequestFilter>),
|
Or(Vec<GroupRequestFilter>),
|
||||||
Not(Box<GroupRequestFilter>),
|
Not(Box<GroupRequestFilter>),
|
||||||
DisplayName(String),
|
DisplayName(String),
|
||||||
|
DisplayNameSubString(SubStringFilter),
|
||||||
Uuid(Uuid),
|
Uuid(Uuid),
|
||||||
GroupId(GroupId),
|
GroupId(GroupId),
|
||||||
// Check if the group contains a user identified by uid.
|
// Check if the group contains a user identified by uid.
|
||||||
Member(UserId),
|
Member(UserId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<bool> for GroupRequestFilter {
|
||||||
|
fn from(val: bool) -> Self {
|
||||||
|
if val {
|
||||||
|
Self::And(vec![])
|
||||||
|
} else {
|
||||||
|
Self::Not(Box::new(Self::And(vec![])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
pub struct CreateUserRequest {
|
pub struct CreateUserRequest {
|
||||||
// Same fields as User, but no creation_date, and with password.
|
// Same fields as User, but no creation_date, and with password.
|
||||||
|
@ -68,13 +122,17 @@ pub struct UpdateGroupRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LoginHandler: Clone + Send {
|
pub trait LoginHandler: Send + Sync {
|
||||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait GroupBackendHandler {
|
pub trait GroupListerBackendHandler {
|
||||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait GroupBackendHandler {
|
||||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||||
|
@ -82,12 +140,16 @@ pub trait GroupBackendHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait UserBackendHandler {
|
pub trait UserListerBackendHandler {
|
||||||
async fn list_users(
|
async fn list_users(
|
||||||
&self,
|
&self,
|
||||||
filters: Option<UserRequestFilter>,
|
filters: Option<UserRequestFilter>,
|
||||||
get_groups: bool,
|
get_groups: bool,
|
||||||
) -> Result<Vec<UserAndGroups>>;
|
) -> Result<Vec<UserAndGroups>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserBackendHandler {
|
||||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||||
|
@ -98,7 +160,15 @@ pub trait UserBackendHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait BackendHandler: Clone + Send + GroupBackendHandler + UserBackendHandler {}
|
pub trait BackendHandler:
|
||||||
|
Send
|
||||||
|
+ Sync
|
||||||
|
+ GroupBackendHandler
|
||||||
|
+ UserBackendHandler
|
||||||
|
+ UserListerBackendHandler
|
||||||
|
+ GroupListerBackendHandler
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mockall::mock! {
|
mockall::mock! {
|
||||||
|
@ -107,16 +177,22 @@ mockall::mock! {
|
||||||
fn clone(&self) -> Self;
|
fn clone(&self) -> Self;
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl GroupBackendHandler for TestBackendHandler {
|
impl GroupListerBackendHandler for TestBackendHandler {
|
||||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl GroupBackendHandler for TestBackendHandler {
|
||||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserBackendHandler for TestBackendHandler {
|
impl UserListerBackendHandler for TestBackendHandler {
|
||||||
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
|
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl UserBackendHandler for TestBackendHandler {
|
||||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||||
|
@ -135,13 +211,21 @@ mockall::mock! {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
fn test_uuid_time() {
|
fn test_uuid_time() {
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
let user_id = "bob";
|
let user_id = "bob";
|
||||||
let date1 = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 11).unwrap();
|
let date1 = Utc
|
||||||
let date2 = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 12).unwrap();
|
.with_ymd_and_hms(2014, 7, 8, 9, 10, 11)
|
||||||
|
.unwrap()
|
||||||
|
.naive_utc();
|
||||||
|
let date2 = Utc
|
||||||
|
.with_ymd_and_hms(2014, 7, 8, 9, 10, 12)
|
||||||
|
.unwrap()
|
||||||
|
.naive_utc();
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
Uuid::from_name_and_date(user_id, &date1),
|
Uuid::from_name_and_date(user_id, &date1),
|
||||||
Uuid::from_name_and_date(user_id, &date2)
|
Uuid::from_name_and_date(user_id, &date2)
|
||||||
|
@ -151,7 +235,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_jpeg_try_from_bytes() {
|
fn test_jpeg_try_from_bytes() {
|
||||||
let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k=";
|
let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k=";
|
||||||
let base64_jpeg = base64::decode(base64_raw).unwrap();
|
let base64_jpeg = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(base64_raw)
|
||||||
|
.unwrap();
|
||||||
JpegPhoto::try_from(base64_jpeg).unwrap();
|
JpegPhoto::try_from(base64_jpeg).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
use ldap3_proto::{
|
use ldap3_proto::{
|
||||||
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
|
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
|
||||||
};
|
};
|
||||||
use tracing::{debug, info, instrument, warn};
|
use tracing::{debug, instrument, warn};
|
||||||
|
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
handler::{BackendHandler, GroupRequestFilter},
|
handler::{GroupListerBackendHandler, GroupRequestFilter},
|
||||||
ldap::error::LdapError,
|
ldap::error::LdapError,
|
||||||
types::{Group, GroupColumn, UserId, Uuid},
|
types::{Group, GroupColumn, UserId, Uuid},
|
||||||
};
|
};
|
||||||
|
@ -12,15 +12,16 @@ use crate::domain::{
|
||||||
use super::{
|
use super::{
|
||||||
error::LdapResult,
|
error::LdapResult,
|
||||||
utils::{
|
utils::{
|
||||||
expand_attribute_wildcards, get_user_id_from_distinguished_name, map_group_field, LdapInfo,
|
expand_attribute_wildcards, get_group_id_from_distinguished_name,
|
||||||
|
get_user_id_from_distinguished_name, map_group_field, LdapInfo,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn get_group_attribute(
|
pub fn get_group_attribute(
|
||||||
group: &Group,
|
group: &Group,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
attribute: &str,
|
attribute: &str,
|
||||||
user_filter: &Option<&UserId>,
|
user_filter: &Option<UserId>,
|
||||||
ignored_group_attributes: &[String],
|
ignored_group_attributes: &[String],
|
||||||
) -> Option<Vec<Vec<u8>>> {
|
) -> Option<Vec<Vec<u8>>> {
|
||||||
let attribute = attribute.to_ascii_lowercase();
|
let attribute = attribute.to_ascii_lowercase();
|
||||||
|
@ -28,12 +29,12 @@ fn get_group_attribute(
|
||||||
"objectclass" => vec![b"groupOfUniqueNames".to_vec()],
|
"objectclass" => vec![b"groupOfUniqueNames".to_vec()],
|
||||||
// Always returned as part of the base response.
|
// Always returned as part of the base response.
|
||||||
"dn" | "distinguishedname" => return None,
|
"dn" | "distinguishedname" => return None,
|
||||||
"cn" | "uid" => vec![group.display_name.clone().into_bytes()],
|
"cn" | "uid" | "id" => vec![group.display_name.clone().into_bytes()],
|
||||||
"entryuuid" => vec![group.uuid.to_string().into_bytes()],
|
"entryuuid" | "uuid" => vec![group.uuid.to_string().into_bytes()],
|
||||||
"member" | "uniquemember" => group
|
"member" | "uniquemember" => group
|
||||||
.users
|
.users
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|u| user_filter.map(|f| *u == f).unwrap_or(true))
|
.filter(|u| user_filter.as_ref().map(|f| *u == f).unwrap_or(true))
|
||||||
.map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes())
|
.map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes())
|
||||||
.collect(),
|
.collect(),
|
||||||
"1.1" => return None,
|
"1.1" => return None,
|
||||||
|
@ -72,14 +73,18 @@ const ALL_GROUP_ATTRIBUTE_KEYS: &[&str] = &[
|
||||||
"entryuuid",
|
"entryuuid",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
fn expand_group_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
|
||||||
|
expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS)
|
||||||
|
}
|
||||||
|
|
||||||
fn make_ldap_search_group_result_entry(
|
fn make_ldap_search_group_result_entry(
|
||||||
group: Group,
|
group: Group,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
attributes: &[String],
|
attributes: &[String],
|
||||||
user_filter: &Option<&UserId>,
|
user_filter: &Option<UserId>,
|
||||||
ignored_group_attributes: &[String],
|
ignored_group_attributes: &[String],
|
||||||
) -> LdapSearchResultEntry {
|
) -> LdapSearchResultEntry {
|
||||||
let expanded_attributes = expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS);
|
let expanded_attributes = expand_group_attribute_wildcards(attributes);
|
||||||
|
|
||||||
LdapSearchResultEntry {
|
LdapSearchResultEntry {
|
||||||
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
|
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
|
||||||
|
@ -120,12 +125,20 @@ fn convert_group_filter(
|
||||||
)?;
|
)?;
|
||||||
Ok(GroupRequestFilter::Member(user_name))
|
Ok(GroupRequestFilter::Member(user_name))
|
||||||
}
|
}
|
||||||
"objectclass" => match value.as_str() {
|
"objectclass" => Ok(GroupRequestFilter::from(matches!(
|
||||||
"groupofuniquenames" | "groupofnames" => Ok(GroupRequestFilter::And(vec![])),
|
value.as_str(),
|
||||||
_ => Ok(GroupRequestFilter::Not(Box::new(GroupRequestFilter::And(
|
"groupofuniquenames" | "groupofnames"
|
||||||
vec![],
|
))),
|
||||||
)))),
|
"dn" => Ok(get_group_id_from_distinguished_name(
|
||||||
},
|
value.to_ascii_lowercase().as_str(),
|
||||||
|
&ldap_info.base_dn,
|
||||||
|
&ldap_info.base_dn_str,
|
||||||
|
)
|
||||||
|
.map(GroupRequestFilter::DisplayName)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
warn!("Invalid dn filter on group: {}", value);
|
||||||
|
GroupRequestFilter::from(false)
|
||||||
|
})),
|
||||||
_ => match map_group_field(field) {
|
_ => match map_group_field(field) {
|
||||||
Some(GroupColumn::DisplayName) => {
|
Some(GroupColumn::DisplayName) => {
|
||||||
Ok(GroupRequestFilter::DisplayName(value.to_string()))
|
Ok(GroupRequestFilter::DisplayName(value.to_string()))
|
||||||
|
@ -144,9 +157,7 @@ fn convert_group_filter(
|
||||||
field
|
field
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(GroupRequestFilter::Not(Box::new(GroupRequestFilter::And(
|
Ok(GroupRequestFilter::from(false))
|
||||||
vec![],
|
|
||||||
))))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -160,16 +171,26 @@ fn convert_group_filter(
|
||||||
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
|
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
|
||||||
LdapFilter::Present(field) => {
|
LdapFilter::Present(field) => {
|
||||||
let field = &field.to_ascii_lowercase();
|
let field = &field.to_ascii_lowercase();
|
||||||
if field == "objectclass"
|
Ok(GroupRequestFilter::from(
|
||||||
|
field == "objectclass"
|
||||||
|| field == "dn"
|
|| field == "dn"
|
||||||
|| field == "distinguishedname"
|
|| field == "distinguishedname"
|
||||||
|| map_group_field(field).is_some()
|
|| map_group_field(field).is_some(),
|
||||||
{
|
))
|
||||||
Ok(GroupRequestFilter::And(vec![]))
|
}
|
||||||
} else {
|
LdapFilter::Substring(field, substring_filter) => {
|
||||||
Ok(GroupRequestFilter::Not(Box::new(GroupRequestFilter::And(
|
let field = &field.to_ascii_lowercase();
|
||||||
vec![],
|
match map_group_field(field.as_str()) {
|
||||||
))))
|
Some(GroupColumn::DisplayName) => Ok(GroupRequestFilter::DisplayNameSubString(
|
||||||
|
substring_filter.clone().into(),
|
||||||
|
)),
|
||||||
|
_ => Err(LdapError {
|
||||||
|
code: LdapResultCode::UnwillingToPerform,
|
||||||
|
message: format!(
|
||||||
|
"Unsupported group attribute for substring filter: {:?}",
|
||||||
|
field
|
||||||
|
),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => Err(LdapError {
|
_ => Err(LdapError {
|
||||||
|
@ -180,42 +201,37 @@ fn convert_group_filter(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
#[instrument(skip_all, level = "debug")]
|
||||||
pub async fn get_groups_list<Backend: BackendHandler>(
|
pub async fn get_groups_list<Backend: GroupListerBackendHandler>(
|
||||||
ldap_info: &LdapInfo,
|
ldap_info: &LdapInfo,
|
||||||
ldap_filter: &LdapFilter,
|
ldap_filter: &LdapFilter,
|
||||||
attributes: &[String],
|
|
||||||
base: &str,
|
base: &str,
|
||||||
user_filter: &Option<&UserId>,
|
backend: &Backend,
|
||||||
backend: &mut Backend,
|
) -> LdapResult<Vec<Group>> {
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
debug!(?ldap_filter);
|
debug!(?ldap_filter);
|
||||||
let filter = convert_group_filter(ldap_info, ldap_filter)?;
|
let filters = convert_group_filter(ldap_info, ldap_filter)?;
|
||||||
let parsed_filters = match user_filter {
|
debug!(?filters);
|
||||||
None => filter,
|
backend
|
||||||
Some(u) => {
|
.list_groups(Some(filters))
|
||||||
info!("Unprivileged search, limiting results");
|
|
||||||
GroupRequestFilter::And(vec![filter, GroupRequestFilter::Member((*u).clone())])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
debug!(?parsed_filters);
|
|
||||||
let groups = backend
|
|
||||||
.list_groups(Some(parsed_filters))
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::Other,
|
code: LdapResultCode::Other,
|
||||||
message: format!(r#"Error while listing groups "{}": {:#}"#, base, e),
|
message: format!(r#"Error while listing groups "{}": {:#}"#, base, e),
|
||||||
})?;
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Ok(groups
|
pub fn convert_groups_to_ldap_op<'a>(
|
||||||
.into_iter()
|
groups: Vec<Group>,
|
||||||
.map(|u| {
|
attributes: &'a [String],
|
||||||
|
ldap_info: &'a LdapInfo,
|
||||||
|
user_filter: &'a Option<UserId>,
|
||||||
|
) -> impl Iterator<Item = LdapOp> + 'a {
|
||||||
|
groups.into_iter().map(move |g| {
|
||||||
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
|
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
|
||||||
u,
|
g,
|
||||||
&ldap_info.base_dn_str,
|
&ldap_info.base_dn_str,
|
||||||
attributes,
|
attributes,
|
||||||
user_filter,
|
user_filter,
|
||||||
&ldap_info.ignored_group_attributes,
|
&ldap_info.ignored_group_attributes,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>())
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
|
use chrono::TimeZone;
|
||||||
use ldap3_proto::{
|
use ldap3_proto::{
|
||||||
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
|
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
|
||||||
};
|
};
|
||||||
use tracing::{debug, info, instrument, warn};
|
use tracing::{debug, instrument, warn};
|
||||||
|
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
handler::{BackendHandler, UserRequestFilter},
|
handler::{UserListerBackendHandler, UserRequestFilter},
|
||||||
ldap::{error::LdapError, utils::expand_attribute_wildcards},
|
ldap::{
|
||||||
types::{GroupDetails, User, UserColumn, UserId},
|
error::LdapError,
|
||||||
|
utils::{expand_attribute_wildcards, get_user_id_from_distinguished_name},
|
||||||
|
},
|
||||||
|
types::{GroupDetails, User, UserAndGroups, UserColumn, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
@ -14,7 +18,7 @@ use super::{
|
||||||
utils::{get_group_id_from_distinguished_name, map_user_field, LdapInfo},
|
utils::{get_group_id_from_distinguished_name, map_user_field, LdapInfo},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn get_user_attribute(
|
pub fn get_user_attribute(
|
||||||
user: &User,
|
user: &User,
|
||||||
attribute: &str,
|
attribute: &str,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
|
@ -31,25 +35,26 @@ fn get_user_attribute(
|
||||||
],
|
],
|
||||||
// dn is always returned as part of the base response.
|
// dn is always returned as part of the base response.
|
||||||
"dn" | "distinguishedname" => return None,
|
"dn" | "distinguishedname" => return None,
|
||||||
"uid" => vec![user.user_id.to_string().into_bytes()],
|
"uid" | "user_id" | "id" => vec![user.user_id.to_string().into_bytes()],
|
||||||
"entryuuid" => vec![user.uuid.to_string().into_bytes()],
|
"entryuuid" | "uuid" => vec![user.uuid.to_string().into_bytes()],
|
||||||
"mail" => vec![user.email.clone().into_bytes()],
|
"mail" | "email" => vec![user.email.clone().into_bytes()],
|
||||||
"givenname" => vec![user.first_name.clone()?.into_bytes()],
|
"givenname" | "first_name" | "firstname" => vec![user.first_name.clone()?.into_bytes()],
|
||||||
"sn" => vec![user.last_name.clone()?.into_bytes()],
|
"sn" | "last_name" | "lastname" => vec![user.last_name.clone()?.into_bytes()],
|
||||||
"jpegphoto" => vec![user.avatar.clone()?.into_bytes()],
|
"jpegphoto" | "avatar" => vec![user.avatar.clone()?.into_bytes()],
|
||||||
"memberof" => groups
|
"memberof" => groups
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|id_and_name| {
|
.map(|id_and_name| {
|
||||||
format!(
|
format!("cn={},ou=groups,{}", &id_and_name.display_name, base_dn_str).into_bytes()
|
||||||
"uid={},ou=groups,{}",
|
|
||||||
&id_and_name.display_name, base_dn_str
|
|
||||||
)
|
|
||||||
.into_bytes()
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
"cn" | "displayname" => vec![user.display_name.clone()?.into_bytes()],
|
"cn" | "displayname" => vec![user.display_name.clone()?.into_bytes()],
|
||||||
"createtimestamp" | "modifytimestamp" => vec![user.creation_date.to_rfc3339().into_bytes()],
|
"creationdate" | "creation_date" | "createtimestamp" | "modifytimestamp" => {
|
||||||
|
vec![chrono::Utc
|
||||||
|
.from_utc_datetime(&user.creation_date)
|
||||||
|
.to_rfc3339()
|
||||||
|
.into_bytes()]
|
||||||
|
}
|
||||||
"1.1" => return None,
|
"1.1" => return None,
|
||||||
// We ignore the operational attribute wildcard.
|
// We ignore the operational attribute wildcard.
|
||||||
"+" => return None,
|
"+" => return None,
|
||||||
|
@ -92,15 +97,15 @@ const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[
|
||||||
fn make_ldap_search_user_result_entry(
|
fn make_ldap_search_user_result_entry(
|
||||||
user: User,
|
user: User,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
attributes: &[&str],
|
attributes: &[String],
|
||||||
groups: Option<&[GroupDetails]>,
|
groups: Option<&[GroupDetails]>,
|
||||||
ignored_user_attributes: &[String],
|
ignored_user_attributes: &[String],
|
||||||
) -> LdapSearchResultEntry {
|
) -> LdapSearchResultEntry {
|
||||||
|
let expanded_attributes = expand_user_attribute_wildcards(attributes);
|
||||||
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
|
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
|
||||||
|
|
||||||
LdapSearchResultEntry {
|
LdapSearchResultEntry {
|
||||||
dn,
|
dn,
|
||||||
attributes: attributes
|
attributes: expanded_attributes
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|a| {
|
.filter_map(|a| {
|
||||||
let values =
|
let values =
|
||||||
|
@ -127,22 +132,27 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
|
||||||
LdapFilter::Equality(field, value) => {
|
LdapFilter::Equality(field, value) => {
|
||||||
let field = &field.to_ascii_lowercase();
|
let field = &field.to_ascii_lowercase();
|
||||||
match field.as_str() {
|
match field.as_str() {
|
||||||
"memberof" => {
|
"memberof" => Ok(UserRequestFilter::MemberOf(
|
||||||
let group_name = get_group_id_from_distinguished_name(
|
get_group_id_from_distinguished_name(
|
||||||
&value.to_ascii_lowercase(),
|
&value.to_ascii_lowercase(),
|
||||||
&ldap_info.base_dn,
|
&ldap_info.base_dn,
|
||||||
&ldap_info.base_dn_str,
|
&ldap_info.base_dn_str,
|
||||||
)?;
|
)?,
|
||||||
Ok(UserRequestFilter::MemberOf(group_name))
|
)),
|
||||||
}
|
"objectclass" => Ok(UserRequestFilter::from(matches!(
|
||||||
"objectclass" => match value.to_ascii_lowercase().as_str() {
|
value.to_ascii_lowercase().as_str(),
|
||||||
"person" | "inetorgperson" | "posixaccount" | "mailaccount" => {
|
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
|
||||||
Ok(UserRequestFilter::And(vec![]))
|
))),
|
||||||
}
|
"dn" => Ok(get_user_id_from_distinguished_name(
|
||||||
_ => Ok(UserRequestFilter::Not(Box::new(UserRequestFilter::And(
|
value.to_ascii_lowercase().as_str(),
|
||||||
vec![],
|
&ldap_info.base_dn,
|
||||||
)))),
|
&ldap_info.base_dn_str,
|
||||||
},
|
)
|
||||||
|
.map(UserRequestFilter::UserId)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
warn!("Invalid dn filter on user: {}", value);
|
||||||
|
UserRequestFilter::from(false)
|
||||||
|
})),
|
||||||
_ => match map_user_field(field) {
|
_ => match map_user_field(field) {
|
||||||
Some(UserColumn::UserId) => Ok(UserRequestFilter::UserId(UserId::new(value))),
|
Some(UserColumn::UserId) => Ok(UserRequestFilter::UserId(UserId::new(value))),
|
||||||
Some(field) => Ok(UserRequestFilter::Equality(field, value.clone())),
|
Some(field) => Ok(UserRequestFilter::Equality(field, value.clone())),
|
||||||
|
@ -154,9 +164,7 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
|
||||||
field
|
field
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(UserRequestFilter::Not(Box::new(UserRequestFilter::And(
|
Ok(UserRequestFilter::from(false))
|
||||||
vec![],
|
|
||||||
))))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -164,16 +172,33 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
|
||||||
LdapFilter::Present(field) => {
|
LdapFilter::Present(field) => {
|
||||||
let field = &field.to_ascii_lowercase();
|
let field = &field.to_ascii_lowercase();
|
||||||
// Check that it's a field we support.
|
// Check that it's a field we support.
|
||||||
if field == "objectclass"
|
Ok(UserRequestFilter::from(
|
||||||
|
field == "objectclass"
|
||||||
|| field == "dn"
|
|| field == "dn"
|
||||||
|| field == "distinguishedname"
|
|| field == "distinguishedname"
|
||||||
|| map_user_field(field).is_some()
|
|| map_user_field(field).is_some(),
|
||||||
{
|
))
|
||||||
Ok(UserRequestFilter::And(vec![]))
|
}
|
||||||
} else {
|
LdapFilter::Substring(field, substring_filter) => {
|
||||||
Ok(UserRequestFilter::Not(Box::new(UserRequestFilter::And(
|
let field = &field.to_ascii_lowercase();
|
||||||
vec![],
|
match map_user_field(field.as_str()) {
|
||||||
))))
|
Some(UserColumn::UserId) => Ok(UserRequestFilter::UserIdSubString(
|
||||||
|
substring_filter.clone().into(),
|
||||||
|
)),
|
||||||
|
None
|
||||||
|
| Some(UserColumn::CreationDate)
|
||||||
|
| Some(UserColumn::Avatar)
|
||||||
|
| Some(UserColumn::Uuid) => Err(LdapError {
|
||||||
|
code: LdapResultCode::UnwillingToPerform,
|
||||||
|
message: format!(
|
||||||
|
"Unsupported user attribute for substring filter: {:?}",
|
||||||
|
field
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
Some(field) => Ok(UserRequestFilter::SubString(
|
||||||
|
field,
|
||||||
|
substring_filter.clone().into(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => Err(LdapError {
|
_ => Err(LdapError {
|
||||||
|
@ -183,47 +208,42 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn expand_user_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
|
||||||
|
expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS)
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug")]
|
#[instrument(skip_all, level = "debug")]
|
||||||
pub async fn get_user_list<Backend: BackendHandler>(
|
pub async fn get_user_list<Backend: UserListerBackendHandler>(
|
||||||
ldap_info: &LdapInfo,
|
ldap_info: &LdapInfo,
|
||||||
ldap_filter: &LdapFilter,
|
ldap_filter: &LdapFilter,
|
||||||
attributes: &[String],
|
request_groups: bool,
|
||||||
base: &str,
|
base: &str,
|
||||||
user_filter: &Option<&UserId>,
|
backend: &Backend,
|
||||||
backend: &mut Backend,
|
) -> LdapResult<Vec<UserAndGroups>> {
|
||||||
) -> LdapResult<Vec<LdapOp>> {
|
|
||||||
debug!(?ldap_filter);
|
debug!(?ldap_filter);
|
||||||
let filters = convert_user_filter(ldap_info, ldap_filter)?;
|
let filters = convert_user_filter(ldap_info, ldap_filter)?;
|
||||||
let parsed_filters = match user_filter {
|
debug!(?filters);
|
||||||
None => filters,
|
backend
|
||||||
Some(u) => {
|
.list_users(Some(filters), request_groups)
|
||||||
info!("Unprivileged search, limiting results");
|
|
||||||
UserRequestFilter::And(vec![filters, UserRequestFilter::UserId((*u).clone())])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
debug!(?parsed_filters);
|
|
||||||
let expanded_attributes = expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS);
|
|
||||||
let need_groups = expanded_attributes
|
|
||||||
.iter()
|
|
||||||
.any(|s| s.to_ascii_lowercase() == "memberof");
|
|
||||||
let users = backend
|
|
||||||
.list_users(Some(parsed_filters), need_groups)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| LdapError {
|
.map_err(|e| LdapError {
|
||||||
code: LdapResultCode::Other,
|
code: LdapResultCode::Other,
|
||||||
message: format!(r#"Error while searching user "{}": {:#}"#, base, e),
|
message: format!(r#"Error while searching user "{}": {:#}"#, base, e),
|
||||||
})?;
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Ok(users
|
pub fn convert_users_to_ldap_op<'a>(
|
||||||
.into_iter()
|
users: Vec<UserAndGroups>,
|
||||||
.map(|u| {
|
attributes: &'a [String],
|
||||||
|
ldap_info: &'a LdapInfo,
|
||||||
|
) -> impl Iterator<Item = LdapOp> + 'a {
|
||||||
|
users.into_iter().map(move |u| {
|
||||||
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
|
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
|
||||||
u.user,
|
u.user,
|
||||||
&ldap_info.base_dn_str,
|
&ldap_info.base_dn_str,
|
||||||
&expanded_attributes,
|
attributes,
|
||||||
u.groups.as_deref(),
|
u.groups.as_deref(),
|
||||||
&ldap_info.ignored_user_attributes,
|
&ldap_info.ignored_user_attributes,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>())
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,29 @@
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use ldap3_proto::LdapResultCode;
|
use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode};
|
||||||
use tracing::{debug, instrument, warn};
|
use tracing::{debug, instrument, warn};
|
||||||
|
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
|
handler::SubStringFilter,
|
||||||
ldap::error::{LdapError, LdapResult},
|
ldap::error::{LdapError, LdapResult},
|
||||||
types::{GroupColumn, UserColumn, UserId},
|
types::{GroupColumn, UserColumn, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
impl From<LdapSubstringFilter> for SubStringFilter {
|
||||||
|
fn from(
|
||||||
|
LdapSubstringFilter {
|
||||||
|
initial,
|
||||||
|
any,
|
||||||
|
final_,
|
||||||
|
}: LdapSubstringFilter,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
initial,
|
||||||
|
any,
|
||||||
|
final_,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
|
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
|
||||||
where
|
where
|
||||||
I: Iterator<Item = String>,
|
I: Iterator<Item = String>,
|
||||||
|
@ -141,9 +158,9 @@ pub fn map_user_field(field: &str) -> Option<UserColumn> {
|
||||||
"uid" | "user_id" | "id" => UserColumn::UserId,
|
"uid" | "user_id" | "id" => UserColumn::UserId,
|
||||||
"mail" | "email" => UserColumn::Email,
|
"mail" | "email" => UserColumn::Email,
|
||||||
"cn" | "displayname" | "display_name" => UserColumn::DisplayName,
|
"cn" | "displayname" | "display_name" => UserColumn::DisplayName,
|
||||||
"givenname" | "first_name" => UserColumn::FirstName,
|
"givenname" | "first_name" | "firstname" => UserColumn::FirstName,
|
||||||
"sn" | "last_name" => UserColumn::LastName,
|
"sn" | "last_name" | "lastname" => UserColumn::LastName,
|
||||||
"avatar" => UserColumn::Avatar,
|
"avatar" | "jpegphoto" => UserColumn::Avatar,
|
||||||
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
|
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
|
||||||
UserColumn::CreationDate
|
UserColumn::CreationDate
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ pub struct Model {
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub group_id: GroupId,
|
pub group_id: GroupId,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
pub creation_date: chrono::NaiveDateTime,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ pub struct Model {
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub refresh_token_hash: i64,
|
pub refresh_token_hash: i64,
|
||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
pub expiry_date: chrono::DateTime<chrono::Utc>,
|
pub expiry_date: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|
|
@ -11,7 +11,7 @@ pub struct Model {
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub jwt_hash: i64,
|
pub jwt_hash: i64,
|
||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
pub expiry_date: chrono::DateTime<chrono::Utc>,
|
pub expiry_date: chrono::NaiveDateTime,
|
||||||
pub blacklisted: bool,
|
pub blacklisted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ pub struct Model {
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
pub expiry_date: chrono::DateTime<chrono::Utc>,
|
pub expiry_date: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
|
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
|
||||||
|
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::{entity::prelude::*, sea_query::BlobSize};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::domain::types::{JpegPhoto, UserId, Uuid};
|
use crate::domain::types::{JpegPhoto, UserId, Uuid};
|
||||||
|
@ -18,7 +18,7 @@ pub struct Model {
|
||||||
pub first_name: Option<String>,
|
pub first_name: Option<String>,
|
||||||
pub last_name: Option<String>,
|
pub last_name: Option<String>,
|
||||||
pub avatar: Option<JpegPhoto>,
|
pub avatar: Option<JpegPhoto>,
|
||||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
pub creation_date: chrono::NaiveDateTime,
|
||||||
pub password_hash: Option<Vec<u8>>,
|
pub password_hash: Option<Vec<u8>>,
|
||||||
pub totp_secret: Option<String>,
|
pub totp_secret: Option<String>,
|
||||||
pub mfa_type: Option<String>,
|
pub mfa_type: Option<String>,
|
||||||
|
@ -56,9 +56,9 @@ impl ColumnTrait for Column {
|
||||||
Column::DisplayName => ColumnType::String(Some(255)),
|
Column::DisplayName => ColumnType::String(Some(255)),
|
||||||
Column::FirstName => ColumnType::String(Some(255)),
|
Column::FirstName => ColumnType::String(Some(255)),
|
||||||
Column::LastName => ColumnType::String(Some(255)),
|
Column::LastName => ColumnType::String(Some(255)),
|
||||||
Column::Avatar => ColumnType::Binary,
|
Column::Avatar => ColumnType::Binary(BlobSize::Long),
|
||||||
Column::CreationDate => ColumnType::DateTime,
|
Column::CreationDate => ColumnType::DateTime,
|
||||||
Column::PasswordHash => ColumnType::Binary,
|
Column::PasswordHash => ColumnType::Binary(BlobSize::Medium),
|
||||||
Column::TotpSecret => ColumnType::String(Some(64)),
|
Column::TotpSecret => ColumnType::String(Some(64)),
|
||||||
Column::MfaType => ColumnType::String(Some(64)),
|
Column::MfaType => ColumnType::String(Some(64)),
|
||||||
Column::Uuid => ColumnType::String(Some(36)),
|
Column::Uuid => ColumnType::String(Some(36)),
|
||||||
|
|
|
@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||||
pub use lldap_auth::{login, registration};
|
pub use lldap_auth::{login, registration};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait OpaqueHandler: Clone + Send {
|
pub trait OpaqueHandler: Send + Sync {
|
||||||
async fn login_start(
|
async fn login_start(
|
||||||
&self,
|
&self,
|
||||||
request: login::ClientLoginStartRequest,
|
request: login::ClientLoginStartRequest,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{handler::BackendHandler, sql_tables::DbConnection};
|
use crate::domain::{handler::BackendHandler, sql_tables::DbConnection};
|
||||||
use crate::infra::configuration::Configuration;
|
use crate::infra::configuration::Configuration;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
@ -23,7 +23,8 @@ pub mod tests {
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::{
|
domain::{
|
||||||
handler::{
|
handler::{
|
||||||
CreateUserRequest, GroupBackendHandler, UserBackendHandler, UserRequestFilter,
|
CreateUserRequest, GroupBackendHandler, UserBackendHandler,
|
||||||
|
UserListerBackendHandler, UserRequestFilter,
|
||||||
},
|
},
|
||||||
sql_tables::init_table,
|
sql_tables::init_table,
|
||||||
types::{GroupId, UserId},
|
types::{GroupId, UserId},
|
||||||
|
|
|
@ -1,20 +1,23 @@
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
error::{DomainError, Result},
|
error::{DomainError, Result},
|
||||||
handler::{GroupBackendHandler, GroupRequestFilter, UpdateGroupRequest},
|
handler::{
|
||||||
|
GroupBackendHandler, GroupListerBackendHandler, GroupRequestFilter, UpdateGroupRequest,
|
||||||
|
},
|
||||||
model::{self, GroupColumn, MembershipColumn},
|
model::{self, GroupColumn, MembershipColumn},
|
||||||
sql_backend_handler::SqlBackendHandler,
|
sql_backend_handler::SqlBackendHandler,
|
||||||
types::{Group, GroupDetails, GroupId, Uuid},
|
types::{Group, GroupDetails, GroupId, Uuid},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
|
sea_query::{Alias, Cond, Expr, Func, IntoCondition, SimpleExpr},
|
||||||
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
|
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||||
QueryTrait,
|
QueryTrait,
|
||||||
};
|
};
|
||||||
use sea_query::{Cond, IntoCondition, SimpleExpr};
|
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
|
fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
|
||||||
use GroupRequestFilter::*;
|
use GroupRequestFilter::*;
|
||||||
|
let group_table = Alias::new("groups");
|
||||||
match filter {
|
match filter {
|
||||||
And(fs) => {
|
And(fs) => {
|
||||||
if fs.is_empty() {
|
if fs.is_empty() {
|
||||||
|
@ -46,11 +49,17 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
|
||||||
.into_query(),
|
.into_query(),
|
||||||
)
|
)
|
||||||
.into_condition(),
|
.into_condition(),
|
||||||
|
DisplayNameSubString(filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col((
|
||||||
|
group_table,
|
||||||
|
GroupColumn::DisplayName,
|
||||||
|
))))
|
||||||
|
.like(filter.to_sql_filter())
|
||||||
|
.into_condition(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl GroupBackendHandler for SqlBackendHandler {
|
impl GroupListerBackendHandler for SqlBackendHandler {
|
||||||
#[instrument(skip_all, level = "debug", ret, err)]
|
#[instrument(skip_all, level = "debug", ret, err)]
|
||||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
||||||
debug!(?filters);
|
debug!(?filters);
|
||||||
|
@ -87,7 +96,10 @@ impl GroupBackendHandler for SqlBackendHandler {
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl GroupBackendHandler for SqlBackendHandler {
|
||||||
#[instrument(skip_all, level = "debug", ret, err)]
|
#[instrument(skip_all, level = "debug", ret, err)]
|
||||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails> {
|
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails> {
|
||||||
debug!(?group_id);
|
debug!(?group_id);
|
||||||
|
@ -116,7 +128,7 @@ impl GroupBackendHandler for SqlBackendHandler {
|
||||||
#[instrument(skip_all, level = "debug", ret, err)]
|
#[instrument(skip_all, level = "debug", ret, err)]
|
||||||
async fn create_group(&self, group_name: &str) -> Result<GroupId> {
|
async fn create_group(&self, group_name: &str) -> Result<GroupId> {
|
||||||
debug!(?group_name);
|
debug!(?group_name);
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now().naive_utc();
|
||||||
let uuid = Uuid::from_name_and_date(group_name, &now);
|
let uuid = Uuid::from_name_and_date(group_name, &now);
|
||||||
let new_group = model::groups::ActiveModel {
|
let new_group = model::groups::ActiveModel {
|
||||||
display_name: ActiveValue::Set(group_name.to_owned()),
|
display_name: ActiveValue::Set(group_name.to_owned()),
|
||||||
|
@ -146,7 +158,7 @@ impl GroupBackendHandler for SqlBackendHandler {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::{sql_backend_handler::tests::*, types::UserId};
|
use crate::domain::{handler::SubStringFilter, sql_backend_handler::tests::*, types::UserId};
|
||||||
|
|
||||||
async fn get_group_ids(
|
async fn get_group_ids(
|
||||||
handler: &SqlBackendHandler,
|
handler: &SqlBackendHandler,
|
||||||
|
@ -221,6 +233,24 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_groups_substring_filter() {
|
||||||
|
let fixture = TestFixture::new().await;
|
||||||
|
assert_eq!(
|
||||||
|
get_group_ids(
|
||||||
|
&fixture.handler,
|
||||||
|
Some(GroupRequestFilter::DisplayNameSubString(SubStringFilter {
|
||||||
|
initial: Some("be".to_owned()),
|
||||||
|
any: vec!["sT".to_owned()],
|
||||||
|
final_: Some("P".to_owned()),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
// Best group
|
||||||
|
vec![fixture.groups[0]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_group_details() {
|
async fn test_get_group_details() {
|
||||||
let fixture = TestFixture::new().await;
|
let fixture = TestFixture::new().await;
|
||||||
|
|
|
@ -2,12 +2,16 @@ use crate::domain::{
|
||||||
sql_tables::{DbConnection, SchemaVersion},
|
sql_tables::{DbConnection, SchemaVersion},
|
||||||
types::{GroupId, UserId, Uuid},
|
types::{GroupId, UserId, Uuid},
|
||||||
};
|
};
|
||||||
use sea_orm::{ConnectionTrait, FromQueryResult, Statement};
|
use sea_orm::{
|
||||||
use sea_query::{ColumnDef, Expr, ForeignKey, ForeignKeyAction, Iden, Query, Table, Value};
|
sea_query::{self, ColumnDef, Expr, ForeignKey, ForeignKeyAction, Query, Table, Value},
|
||||||
|
ConnectionTrait, FromQueryResult, Iden, Statement, TransactionTrait,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{instrument, warn};
|
use tracing::{info, instrument, warn};
|
||||||
|
|
||||||
#[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
use super::sql_tables::LAST_SCHEMA_VERSION;
|
||||||
|
|
||||||
|
#[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
|
||||||
pub enum Users {
|
pub enum Users {
|
||||||
Table,
|
Table,
|
||||||
UserId,
|
UserId,
|
||||||
|
@ -23,7 +27,7 @@ pub enum Users {
|
||||||
Uuid,
|
Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
#[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
|
||||||
pub enum Groups {
|
pub enum Groups {
|
||||||
Table,
|
Table,
|
||||||
GroupId,
|
GroupId,
|
||||||
|
@ -32,7 +36,7 @@ pub enum Groups {
|
||||||
Uuid,
|
Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Iden)]
|
#[derive(Iden, Clone, Copy)]
|
||||||
pub enum Memberships {
|
pub enum Memberships {
|
||||||
Table,
|
Table,
|
||||||
UserId,
|
UserId,
|
||||||
|
@ -116,6 +120,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||||
.col(
|
.col(
|
||||||
ColumnDef::new(Groups::GroupId)
|
ColumnDef::new(Groups::GroupId)
|
||||||
.integer()
|
.integer()
|
||||||
|
.auto_increment()
|
||||||
.not_null()
|
.not_null()
|
||||||
.primary_key(),
|
.primary_key(),
|
||||||
)
|
)
|
||||||
|
@ -169,7 +174,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||||
struct ShortGroupDetails {
|
struct ShortGroupDetails {
|
||||||
group_id: GroupId,
|
group_id: GroupId,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
creation_date: chrono::DateTime<chrono::Utc>,
|
creation_date: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
for result in ShortGroupDetails::find_by_statement(
|
for result in ShortGroupDetails::find_by_statement(
|
||||||
builder.build(
|
builder.build(
|
||||||
|
@ -219,7 +224,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||||
#[derive(FromQueryResult)]
|
#[derive(FromQueryResult)]
|
||||||
struct ShortUserDetails {
|
struct ShortUserDetails {
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
creation_date: chrono::DateTime<chrono::Utc>,
|
creation_date: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
for result in ShortUserDetails::find_by_statement(
|
for result in ShortUserDetails::find_by_statement(
|
||||||
builder.build(
|
builder.build(
|
||||||
|
@ -309,7 +314,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||||
Table::create()
|
Table::create()
|
||||||
.table(Metadata::Table)
|
.table(Metadata::Table)
|
||||||
.if_not_exists()
|
.if_not_exists()
|
||||||
.col(ColumnDef::new(Metadata::Version).tiny_integer()),
|
.col(ColumnDef::new(Metadata::Version).small_integer()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -329,12 +334,158 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn migrate_from_version(
|
async fn replace_column<I: Iden + Copy + 'static, const N: usize>(
|
||||||
_pool: &DbConnection,
|
pool: &DbConnection,
|
||||||
version: SchemaVersion,
|
table_name: I,
|
||||||
|
column_name: I,
|
||||||
|
mut new_column: ColumnDef,
|
||||||
|
update_values: [Statement; N],
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
if version.0 > 1 {
|
// Update the definition of a column (in a compatible way). Due to Sqlite, this is more complicated:
|
||||||
anyhow::bail!("DB version downgrading is not supported");
|
// - rename the column to a temporary name
|
||||||
|
// - create the column with the new definition
|
||||||
|
// - copy the data from the temp column to the new one
|
||||||
|
// - update the new one if there are changes needed
|
||||||
|
// - drop the old one
|
||||||
|
let builder = pool.get_database_backend();
|
||||||
|
pool.transaction::<_, (), sea_orm::DbErr>(move |transaction| {
|
||||||
|
Box::pin(async move {
|
||||||
|
#[derive(Iden)]
|
||||||
|
enum TempTable {
|
||||||
|
TempName,
|
||||||
}
|
}
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
builder.build(
|
||||||
|
Table::alter()
|
||||||
|
.table(table_name)
|
||||||
|
.rename_column(column_name, TempTable::TempName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
builder.build(Table::alter().table(table_name).add_column(&mut new_column)),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
builder.build(
|
||||||
|
Query::update()
|
||||||
|
.table(table_name)
|
||||||
|
.value(column_name, Expr::col((table_name, TempTable::TempName))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
for statement in update_values {
|
||||||
|
transaction.execute(statement).await?;
|
||||||
|
}
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
builder.build(
|
||||||
|
Table::alter()
|
||||||
|
.table(table_name)
|
||||||
|
.drop_column(TempTable::TempName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate_to_v2(pool: &DbConnection) -> anyhow::Result<()> {
|
||||||
|
let builder = pool.get_database_backend();
|
||||||
|
// Allow nulls in DisplayName, and change empty string to null.
|
||||||
|
replace_column(
|
||||||
|
pool,
|
||||||
|
Users::Table,
|
||||||
|
Users::DisplayName,
|
||||||
|
ColumnDef::new(Users::DisplayName)
|
||||||
|
.string_len(255)
|
||||||
|
.to_owned(),
|
||||||
|
[builder.build(
|
||||||
|
Query::update()
|
||||||
|
.table(Users::Table)
|
||||||
|
.value(Users::DisplayName, Option::<String>::None)
|
||||||
|
.cond_where(Expr::col(Users::DisplayName).eq("")),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate_to_v3(pool: &DbConnection) -> anyhow::Result<()> {
|
||||||
|
let builder = pool.get_database_backend();
|
||||||
|
// Allow nulls in First and LastName. Users who created their DB in 0.4.1 have the not null constraint.
|
||||||
|
replace_column(
|
||||||
|
pool,
|
||||||
|
Users::Table,
|
||||||
|
Users::FirstName,
|
||||||
|
ColumnDef::new(Users::FirstName).string_len(255).to_owned(),
|
||||||
|
[builder.build(
|
||||||
|
Query::update()
|
||||||
|
.table(Users::Table)
|
||||||
|
.value(Users::FirstName, Option::<String>::None)
|
||||||
|
.cond_where(Expr::col(Users::FirstName).eq("")),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
replace_column(
|
||||||
|
pool,
|
||||||
|
Users::Table,
|
||||||
|
Users::LastName,
|
||||||
|
ColumnDef::new(Users::LastName).string_len(255).to_owned(),
|
||||||
|
[builder.build(
|
||||||
|
Query::update()
|
||||||
|
.table(Users::Table)
|
||||||
|
.value(Users::LastName, Option::<String>::None)
|
||||||
|
.cond_where(Expr::col(Users::LastName).eq("")),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Change Avatar from binary to blob(long), because for MySQL this is 64kb.
|
||||||
|
replace_column(
|
||||||
|
pool,
|
||||||
|
Users::Table,
|
||||||
|
Users::Avatar,
|
||||||
|
ColumnDef::new(Users::Avatar)
|
||||||
|
.blob(sea_query::BlobSize::Long)
|
||||||
|
.to_owned(),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn migrate_from_version(
|
||||||
|
pool: &DbConnection,
|
||||||
|
version: SchemaVersion,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match version.cmp(&LAST_SCHEMA_VERSION) {
|
||||||
|
std::cmp::Ordering::Less => info!(
|
||||||
|
"Upgrading DB schema from {} to {}",
|
||||||
|
version.0, LAST_SCHEMA_VERSION.0
|
||||||
|
),
|
||||||
|
std::cmp::Ordering::Equal => return Ok(()),
|
||||||
|
std::cmp::Ordering::Greater => anyhow::bail!("DB version downgrading is not supported"),
|
||||||
|
}
|
||||||
|
if version < SchemaVersion(2) {
|
||||||
|
migrate_to_v2(pool).await?;
|
||||||
|
}
|
||||||
|
if version < SchemaVersion(3) {
|
||||||
|
migrate_to_v3(pool).await?;
|
||||||
|
}
|
||||||
|
let builder = pool.get_database_backend();
|
||||||
|
pool.execute(
|
||||||
|
builder.build(
|
||||||
|
Query::update()
|
||||||
|
.table(Metadata::Table)
|
||||||
|
.value(Metadata::Version, Value::from(LAST_SCHEMA_VERSION)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,9 @@ use super::{
|
||||||
types::UserId,
|
types::UserId,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use base64::Engine;
|
||||||
use lldap_auth::opaque;
|
use lldap_auth::opaque;
|
||||||
use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait, FromQueryResult, QuerySelect};
|
use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait, QuerySelect};
|
||||||
use secstr::SecUtf8;
|
use secstr::SecUtf8;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
|
@ -50,18 +51,14 @@ impl SqlBackendHandler {
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug", err)]
|
#[instrument(skip_all, level = "debug", err)]
|
||||||
async fn get_password_file_for_user(&self, user_id: UserId) -> Result<Option<Vec<u8>>> {
|
async fn get_password_file_for_user(&self, user_id: UserId) -> Result<Option<Vec<u8>>> {
|
||||||
#[derive(FromQueryResult)]
|
|
||||||
struct OnlyPasswordHash {
|
|
||||||
password_hash: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
// Fetch the previously registered password file from the DB.
|
// Fetch the previously registered password file from the DB.
|
||||||
Ok(model::User::find_by_id(user_id)
|
Ok(model::User::find_by_id(user_id)
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(UserColumn::PasswordHash)
|
.column(UserColumn::PasswordHash)
|
||||||
.into_model::<OnlyPasswordHash>()
|
.into_tuple::<(Option<Vec<u8>>,)>()
|
||||||
.one(&self.sql_pool)
|
.one(&self.sql_pool)
|
||||||
.await?
|
.await?
|
||||||
.and_then(|u| u.password_hash))
|
.and_then(|u| u.0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,7 +130,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||||
let encrypted_state = orion::aead::seal(&secret_key, &bincode::serialize(&server_data)?)?;
|
let encrypted_state = orion::aead::seal(&secret_key, &bincode::serialize(&server_data)?)?;
|
||||||
|
|
||||||
Ok(login::ServerLoginStartResponse {
|
Ok(login::ServerLoginStartResponse {
|
||||||
server_data: base64::encode(&encrypted_state),
|
server_data: base64::engine::general_purpose::STANDARD.encode(encrypted_state),
|
||||||
credential_response: start_response.message,
|
credential_response: start_response.message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -146,7 +143,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||||
server_login,
|
server_login,
|
||||||
} = bincode::deserialize(&orion::aead::open(
|
} = bincode::deserialize(&orion::aead::open(
|
||||||
&secret_key,
|
&secret_key,
|
||||||
&base64::decode(&request.server_data)?,
|
&base64::engine::general_purpose::STANDARD.decode(&request.server_data)?,
|
||||||
)?)?;
|
)?)?;
|
||||||
// Finish the login: this makes sure the client data is correct, and gives a session key we
|
// Finish the login: this makes sure the client data is correct, and gives a session key we
|
||||||
// don't need.
|
// don't need.
|
||||||
|
@ -174,7 +171,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||||
};
|
};
|
||||||
let encrypted_state = orion::aead::seal(&secret_key, &bincode::serialize(&server_data)?)?;
|
let encrypted_state = orion::aead::seal(&secret_key, &bincode::serialize(&server_data)?)?;
|
||||||
Ok(registration::ServerRegistrationStartResponse {
|
Ok(registration::ServerRegistrationStartResponse {
|
||||||
server_data: base64::encode(encrypted_state),
|
server_data: base64::engine::general_purpose::STANDARD.encode(encrypted_state),
|
||||||
registration_response: start_response.message,
|
registration_response: start_response.message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -187,7 +184,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||||
let secret_key = self.get_orion_secret_key()?;
|
let secret_key = self.get_orion_secret_key()?;
|
||||||
let registration::ServerData { username } = bincode::deserialize(&orion::aead::open(
|
let registration::ServerData { username } = bincode::deserialize(&orion::aead::open(
|
||||||
&secret_key,
|
&secret_key,
|
||||||
&base64::decode(&request.server_data)?,
|
&base64::engine::general_purpose::STANDARD.decode(&request.server_data)?,
|
||||||
)?)?;
|
)?)?;
|
||||||
|
|
||||||
let password_file =
|
let password_file =
|
||||||
|
|
|
@ -3,16 +3,15 @@ use sea_orm::Value;
|
||||||
|
|
||||||
pub type DbConnection = sea_orm::DatabaseConnection;
|
pub type DbConnection = sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
#[derive(Copy, PartialEq, Eq, Debug, Clone)]
|
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
|
||||||
pub struct SchemaVersion(pub u8);
|
pub struct SchemaVersion(pub i16);
|
||||||
|
|
||||||
impl sea_orm::TryGetable for SchemaVersion {
|
impl sea_orm::TryGetable for SchemaVersion {
|
||||||
fn try_get(
|
fn try_get_by<I: sea_orm::ColIdx>(
|
||||||
res: &sea_orm::QueryResult,
|
res: &sea_orm::QueryResult,
|
||||||
pre: &str,
|
index: I,
|
||||||
col: &str,
|
|
||||||
) -> Result<Self, sea_orm::TryGetError> {
|
) -> Result<Self, sea_orm::TryGetError> {
|
||||||
Ok(SchemaVersion(u8::try_get(res, pre, col)?))
|
Ok(SchemaVersion(i16::try_get_by(res, index)?))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,6 +21,8 @@ impl From<SchemaVersion> for Value {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(3);
|
||||||
|
|
||||||
pub async fn init_table(pool: &DbConnection) -> anyhow::Result<()> {
|
pub async fn init_table(pool: &DbConnection) -> anyhow::Result<()> {
|
||||||
let version = {
|
let version = {
|
||||||
if let Some(version) = get_schema_version(pool).await {
|
if let Some(version) = get_schema_version(pool).await {
|
||||||
|
@ -67,7 +68,7 @@ mod tests {
|
||||||
#[derive(FromQueryResult, PartialEq, Eq, Debug)]
|
#[derive(FromQueryResult, PartialEq, Eq, Debug)]
|
||||||
struct ShortUserDetails {
|
struct ShortUserDetails {
|
||||||
display_name: String,
|
display_name: String,
|
||||||
creation_date: chrono::DateTime<chrono::Utc>,
|
creation_date: chrono::NaiveDateTime,
|
||||||
}
|
}
|
||||||
let result = ShortUserDetails::find_by_statement(raw_statement(
|
let result = ShortUserDetails::find_by_statement(raw_statement(
|
||||||
r#"SELECT display_name, creation_date FROM users WHERE user_id = "bôb""#,
|
r#"SELECT display_name, creation_date FROM users WHERE user_id = "bôb""#,
|
||||||
|
@ -80,7 +81,7 @@ mod tests {
|
||||||
result,
|
result,
|
||||||
ShortUserDetails {
|
ShortUserDetails {
|
||||||
display_name: "Bob Bobbersön".to_owned(),
|
display_name: "Bob Bobbersön".to_owned(),
|
||||||
creation_date: Utc.timestamp_opt(0, 0).unwrap()
|
creation_date: Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -99,14 +100,21 @@ mod tests {
|
||||||
let sql_pool = get_in_memory_db().await;
|
let sql_pool = get_in_memory_db().await;
|
||||||
sql_pool
|
sql_pool
|
||||||
.execute(raw_statement(
|
.execute(raw_statement(
|
||||||
r#"CREATE TABLE users ( user_id TEXT , creation_date TEXT);"#,
|
r#"CREATE TABLE users ( user_id TEXT, display_name TEXT, first_name TEXT NOT NULL, last_name TEXT, avatar BLOB, creation_date TEXT);"#,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
sql_pool
|
sql_pool
|
||||||
.execute(raw_statement(
|
.execute(raw_statement(
|
||||||
r#"INSERT INTO users (user_id, creation_date)
|
r#"INSERT INTO users (user_id, display_name, first_name, creation_date)
|
||||||
VALUES ("bôb", "1970-01-01 00:00:00")"#,
|
VALUES ("bôb", "", "", "1970-01-01 00:00:00")"#,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sql_pool
|
||||||
|
.execute(raw_statement(
|
||||||
|
r#"INSERT INTO users (user_id, display_name, first_name, creation_date)
|
||||||
|
VALUES ("john", "John Doe", "John", "1971-01-01 00:00:00")"#,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
@ -132,17 +140,30 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
#[derive(FromQueryResult, PartialEq, Eq, Debug)]
|
#[derive(FromQueryResult, PartialEq, Eq, Debug)]
|
||||||
struct JustUuid {
|
struct SimpleUser {
|
||||||
|
display_name: Option<String>,
|
||||||
|
first_name: Option<String>,
|
||||||
uuid: Uuid,
|
uuid: Uuid,
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
JustUuid::find_by_statement(raw_statement(r#"SELECT uuid FROM users"#))
|
SimpleUser::find_by_statement(raw_statement(
|
||||||
|
r#"SELECT display_name, first_name, uuid FROM users ORDER BY display_name"#
|
||||||
|
))
|
||||||
.all(&sql_pool)
|
.all(&sql_pool)
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
vec![JustUuid {
|
vec![
|
||||||
|
SimpleUser {
|
||||||
|
display_name: None,
|
||||||
|
first_name: None,
|
||||||
uuid: crate::uuid!("a02eaf13-48a7-30f6-a3d4-040ff7c52b04")
|
uuid: crate::uuid!("a02eaf13-48a7-30f6-a3d4-040ff7c52b04")
|
||||||
}]
|
},
|
||||||
|
SimpleUser {
|
||||||
|
display_name: Some("John Doe".to_owned()),
|
||||||
|
first_name: Some("John".to_owned()),
|
||||||
|
uuid: crate::uuid!("986765a5-3f03-389e-b47b-536b2d6e1bec")
|
||||||
|
}
|
||||||
|
]
|
||||||
);
|
);
|
||||||
#[derive(FromQueryResult, PartialEq, Eq, Debug)]
|
#[derive(FromQueryResult, PartialEq, Eq, Debug)]
|
||||||
struct ShortGroupDetails {
|
struct ShortGroupDetails {
|
||||||
|
@ -180,7 +201,7 @@ mod tests {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
sql_migrations::JustSchemaVersion {
|
sql_migrations::JustSchemaVersion {
|
||||||
version: SchemaVersion(1)
|
version: LAST_SCHEMA_VERSION
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
use super::{
|
use crate::domain::{
|
||||||
error::{DomainError, Result},
|
error::{DomainError, Result},
|
||||||
handler::{CreateUserRequest, UpdateUserRequest, UserBackendHandler, UserRequestFilter},
|
handler::{
|
||||||
|
CreateUserRequest, UpdateUserRequest, UserBackendHandler, UserListerBackendHandler,
|
||||||
|
UserRequestFilter,
|
||||||
|
},
|
||||||
model::{self, GroupColumn, UserColumn},
|
model::{self, GroupColumn, UserColumn},
|
||||||
sql_backend_handler::SqlBackendHandler,
|
sql_backend_handler::SqlBackendHandler,
|
||||||
types::{GroupDetails, GroupId, User, UserAndGroups, UserId, Uuid},
|
types::{GroupDetails, GroupId, User, UserAndGroups, UserId, Uuid},
|
||||||
|
@ -8,11 +11,10 @@ use super::{
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
entity::IntoActiveValue,
|
entity::IntoActiveValue,
|
||||||
sea_query::{Cond, Expr, IntoCondition, SimpleExpr},
|
sea_query::{Alias, Cond, Expr, Func, IntoColumnRef, IntoCondition, SimpleExpr},
|
||||||
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder,
|
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder,
|
||||||
QuerySelect, QueryTrait, Set,
|
QuerySelect, QueryTrait, Set,
|
||||||
};
|
};
|
||||||
use sea_query::{Alias, IntoColumnRef};
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
|
@ -50,8 +52,15 @@ fn get_user_filter_expr(filter: UserRequestFilter) -> Cond {
|
||||||
MemberOfId(group_id) => Expr::col((group_table, GroupColumn::GroupId))
|
MemberOfId(group_id) => Expr::col((group_table, GroupColumn::GroupId))
|
||||||
.eq(group_id)
|
.eq(group_id)
|
||||||
.into_condition(),
|
.into_condition(),
|
||||||
|
UserIdSubString(filter) => UserColumn::UserId
|
||||||
|
.like(&filter.to_sql_filter())
|
||||||
|
.into_condition(),
|
||||||
|
SubString(col, filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col(col)))
|
||||||
|
.like(filter.to_sql_filter())
|
||||||
|
.into_condition(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_value(opt_name: &Option<String>) -> ActiveValue<Option<String>> {
|
fn to_value(opt_name: &Option<String>) -> ActiveValue<Option<String>> {
|
||||||
match opt_name {
|
match opt_name {
|
||||||
None => ActiveValue::NotSet,
|
None => ActiveValue::NotSet,
|
||||||
|
@ -64,7 +73,7 @@ fn to_value(opt_name: &Option<String>) -> ActiveValue<Option<String>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserBackendHandler for SqlBackendHandler {
|
impl UserListerBackendHandler for SqlBackendHandler {
|
||||||
#[instrument(skip_all, level = "debug", ret, err)]
|
#[instrument(skip_all, level = "debug", ret, err)]
|
||||||
async fn list_users(
|
async fn list_users(
|
||||||
&self,
|
&self,
|
||||||
|
@ -129,7 +138,10 @@ impl UserBackendHandler for SqlBackendHandler {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserBackendHandler for SqlBackendHandler {
|
||||||
#[instrument(skip_all, level = "debug", ret)]
|
#[instrument(skip_all, level = "debug", ret)]
|
||||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User> {
|
async fn get_user_details(&self, user_id: &UserId) -> Result<User> {
|
||||||
debug!(?user_id);
|
debug!(?user_id);
|
||||||
|
@ -158,7 +170,7 @@ impl UserBackendHandler for SqlBackendHandler {
|
||||||
#[instrument(skip_all, level = "debug", err)]
|
#[instrument(skip_all, level = "debug", err)]
|
||||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()> {
|
async fn create_user(&self, request: CreateUserRequest) -> Result<()> {
|
||||||
debug!(user_id = ?request.user_id);
|
debug!(user_id = ?request.user_id);
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now().naive_utc();
|
||||||
let uuid = Uuid::from_name_and_date(request.user_id.as_str(), &now);
|
let uuid = Uuid::from_name_and_date(request.user_id.as_str(), &now);
|
||||||
let new_user = model::users::ActiveModel {
|
let new_user = model::users::ActiveModel {
|
||||||
user_id: Set(request.user_id),
|
user_id: Set(request.user_id),
|
||||||
|
@ -237,6 +249,7 @@ impl UserBackendHandler for SqlBackendHandler {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
|
handler::SubStringFilter,
|
||||||
sql_backend_handler::tests::*,
|
sql_backend_handler::tests::*,
|
||||||
types::{JpegPhoto, UserColumn},
|
types::{JpegPhoto, UserColumn},
|
||||||
};
|
};
|
||||||
|
@ -287,6 +300,31 @@ mod tests {
|
||||||
assert_eq!(users, vec!["bob"]);
|
assert_eq!(users, vec!["bob"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_users_substring_filter() {
|
||||||
|
let fixture = TestFixture::new().await;
|
||||||
|
let users = get_user_names(
|
||||||
|
&fixture.handler,
|
||||||
|
Some(UserRequestFilter::And(vec![
|
||||||
|
UserRequestFilter::UserIdSubString(SubStringFilter {
|
||||||
|
initial: Some("Pa".to_owned()),
|
||||||
|
any: vec!["rI".to_owned()],
|
||||||
|
final_: Some("K".to_owned()),
|
||||||
|
}),
|
||||||
|
UserRequestFilter::SubString(
|
||||||
|
UserColumn::FirstName,
|
||||||
|
SubStringFilter {
|
||||||
|
initial: None,
|
||||||
|
any: vec!["r".to_owned(), "t".to_owned()],
|
||||||
|
final_: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(users, vec!["patrick"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_list_users_false_filter() {
|
async fn test_list_users_false_filter() {
|
||||||
let fixture = TestFixture::new().await;
|
let fixture = TestFixture::new().await;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
use base64::Engine;
|
||||||
|
use chrono::{NaiveDateTime, TimeZone};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
entity::IntoActiveValue,
|
entity::IntoActiveValue,
|
||||||
sea_query::{value::ValueType, ArrayType, ColumnType, Nullable, ValueTypeErr},
|
sea_query::{value::ValueType, ArrayType, ColumnType, Nullable, ValueTypeErr},
|
||||||
|
@ -7,18 +9,23 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub use super::model::{GroupColumn, UserColumn};
|
pub use super::model::{GroupColumn, UserColumn};
|
||||||
|
|
||||||
pub type DateTime = chrono::DateTime<chrono::Utc>;
|
|
||||||
|
|
||||||
#[derive(PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize)]
|
#[derive(PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
#[serde(try_from = "&str")]
|
#[serde(try_from = "&str")]
|
||||||
pub struct Uuid(String);
|
pub struct Uuid(String);
|
||||||
|
|
||||||
impl Uuid {
|
impl Uuid {
|
||||||
pub fn from_name_and_date(name: &str, creation_date: &DateTime) -> Self {
|
pub fn from_name_and_date(name: &str, creation_date: &NaiveDateTime) -> Self {
|
||||||
Uuid(
|
Uuid(
|
||||||
uuid::Uuid::new_v3(
|
uuid::Uuid::new_v3(
|
||||||
&uuid::Uuid::NAMESPACE_X500,
|
&uuid::Uuid::NAMESPACE_X500,
|
||||||
&[name.as_bytes(), creation_date.to_rfc3339().as_bytes()].concat(),
|
&[
|
||||||
|
name.as_bytes(),
|
||||||
|
chrono::Utc
|
||||||
|
.from_utc_datetime(creation_date)
|
||||||
|
.to_rfc3339()
|
||||||
|
.as_bytes(),
|
||||||
|
]
|
||||||
|
.concat(),
|
||||||
)
|
)
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
|
@ -47,8 +54,11 @@ impl std::string::ToString for Uuid {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryGetable for Uuid {
|
impl TryGetable for Uuid {
|
||||||
fn try_get(res: &QueryResult, pre: &str, col: &str) -> std::result::Result<Self, TryGetError> {
|
fn try_get_by<I: sea_orm::ColIdx>(
|
||||||
Ok(Uuid(String::try_get(res, pre, col)?))
|
res: &QueryResult,
|
||||||
|
index: I,
|
||||||
|
) -> std::result::Result<Self, TryGetError> {
|
||||||
|
Ok(Uuid(String::try_get_by(res, index)?))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,8 +146,8 @@ impl From<&UserId> for Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryGetable for UserId {
|
impl TryGetable for UserId {
|
||||||
fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
|
fn try_get_by<I: sea_orm::ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
|
||||||
Ok(UserId::new(&String::try_get(res, pre, col)?))
|
Ok(UserId::new(&String::try_get_by(res, index)?))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -215,13 +225,15 @@ impl TryFrom<String> for JpegPhoto {
|
||||||
type Error = anyhow::Error;
|
type Error = anyhow::Error;
|
||||||
fn try_from(string: String) -> anyhow::Result<Self> {
|
fn try_from(string: String) -> anyhow::Result<Self> {
|
||||||
// The String format is in base64.
|
// The String format is in base64.
|
||||||
<Self as TryFrom<_>>::try_from(base64::decode(string.as_str())?)
|
<Self as TryFrom<_>>::try_from(
|
||||||
|
base64::engine::general_purpose::STANDARD.decode(string.as_str())?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&JpegPhoto> for String {
|
impl From<&JpegPhoto> for String {
|
||||||
fn from(val: &JpegPhoto) -> Self {
|
fn from(val: &JpegPhoto) -> Self {
|
||||||
base64::encode(&val.0)
|
base64::engine::general_purpose::STANDARD.encode(&val.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -255,8 +267,8 @@ impl JpegPhoto {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryGetable for JpegPhoto {
|
impl TryGetable for JpegPhoto {
|
||||||
fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
|
fn try_get_by<I: sea_orm::ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
|
||||||
<Self as std::convert::TryFrom<Vec<_>>>::try_from(Vec::<u8>::try_get(res, pre, col)?)
|
<Self as std::convert::TryFrom<Vec<_>>>::try_from(Vec::<u8>::try_get_by(res, index)?)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
TryGetError::DbErr(DbErr::TryIntoErr {
|
TryGetError::DbErr(DbErr::TryIntoErr {
|
||||||
from: "[u8]",
|
from: "[u8]",
|
||||||
|
@ -308,15 +320,14 @@ pub struct User {
|
||||||
pub first_name: Option<String>,
|
pub first_name: Option<String>,
|
||||||
pub last_name: Option<String>,
|
pub last_name: Option<String>,
|
||||||
pub avatar: Option<JpegPhoto>,
|
pub avatar: Option<JpegPhoto>,
|
||||||
pub creation_date: DateTime,
|
pub creation_date: NaiveDateTime,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
impl Default for User {
|
impl Default for User {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
use chrono::TimeZone;
|
let epoch = chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc();
|
||||||
let epoch = chrono::Utc.timestamp_opt(0, 0).unwrap();
|
|
||||||
User {
|
User {
|
||||||
user_id: UserId::default(),
|
user_id: UserId::default(),
|
||||||
email: String::new(),
|
email: String::new(),
|
||||||
|
@ -340,8 +351,8 @@ impl From<GroupId> for Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryGetable for GroupId {
|
impl TryGetable for GroupId {
|
||||||
fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
|
fn try_get_by<I: sea_orm::ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
|
||||||
Ok(GroupId(i32::try_get(res, pre, col)?))
|
Ok(GroupId(i32::try_get_by(res, index)?))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -359,7 +370,7 @@ impl ValueType for GroupId {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn column_type() -> ColumnType {
|
fn column_type() -> ColumnType {
|
||||||
ColumnType::Integer(None)
|
ColumnType::Integer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -373,7 +384,7 @@ impl TryFromU64 for GroupId {
|
||||||
pub struct Group {
|
pub struct Group {
|
||||||
pub id: GroupId,
|
pub id: GroupId,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub creation_date: DateTime,
|
pub creation_date: NaiveDateTime,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
pub users: Vec<UserId>,
|
pub users: Vec<UserId>,
|
||||||
}
|
}
|
||||||
|
@ -382,7 +393,7 @@ pub struct Group {
|
||||||
pub struct GroupDetails {
|
pub struct GroupDetails {
|
||||||
pub group_id: GroupId,
|
pub group_id: GroupId,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub creation_date: DateTime,
|
pub creation_date: NaiveDateTime,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
317
server/src/infra/access_control.rs
Normal file
317
server/src/infra/access_control.rs
Normal file
|
@ -0,0 +1,317 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::domain::{
|
||||||
|
error::Result,
|
||||||
|
handler::{
|
||||||
|
BackendHandler, CreateUserRequest, GroupListerBackendHandler, GroupRequestFilter,
|
||||||
|
UpdateGroupRequest, UpdateUserRequest, UserListerBackendHandler, UserRequestFilter,
|
||||||
|
},
|
||||||
|
types::{Group, GroupDetails, GroupId, User, UserAndGroups, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Permission {
|
||||||
|
Admin,
|
||||||
|
PasswordManager,
|
||||||
|
Readonly,
|
||||||
|
Regular,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ValidationResults {
|
||||||
|
pub user: UserId,
|
||||||
|
pub permission: Permission,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidationResults {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn admin() -> Self {
|
||||||
|
Self {
|
||||||
|
user: UserId::new("admin"),
|
||||||
|
permission: Permission::Admin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
self.permission == Permission::Admin
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn can_read_all(&self) -> bool {
|
||||||
|
self.permission == Permission::Admin
|
||||||
|
|| self.permission == Permission::Readonly
|
||||||
|
|| self.permission == Permission::PasswordManager
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn can_read(&self, user: &UserId) -> bool {
|
||||||
|
self.permission == Permission::Admin
|
||||||
|
|| self.permission == Permission::PasswordManager
|
||||||
|
|| self.permission == Permission::Readonly
|
||||||
|
|| &self.user == user
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
|
||||||
|
self.permission == Permission::Admin
|
||||||
|
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|
||||||
|
|| &self.user == user
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn can_write(&self, user: &UserId) -> bool {
|
||||||
|
self.permission == Permission::Admin || &self.user == user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserReadableBackendHandler {
|
||||||
|
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||||
|
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ReadonlyBackendHandler: UserReadableBackendHandler {
|
||||||
|
async fn list_users(
|
||||||
|
&self,
|
||||||
|
filters: Option<UserRequestFilter>,
|
||||||
|
get_groups: bool,
|
||||||
|
) -> Result<Vec<UserAndGroups>>;
|
||||||
|
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||||
|
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserWriteableBackendHandler: UserReadableBackendHandler {
|
||||||
|
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AdminBackendHandler:
|
||||||
|
UserWriteableBackendHandler + ReadonlyBackendHandler + UserWriteableBackendHandler
|
||||||
|
{
|
||||||
|
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||||
|
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
|
||||||
|
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||||
|
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||||
|
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||||
|
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||||
|
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<Handler: BackendHandler> UserReadableBackendHandler for Handler {
|
||||||
|
async fn get_user_details(&self, user_id: &UserId) -> Result<User> {
|
||||||
|
self.get_user_details(user_id).await
|
||||||
|
}
|
||||||
|
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>> {
|
||||||
|
self.get_user_groups(user_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<Handler: BackendHandler> ReadonlyBackendHandler for Handler {
|
||||||
|
async fn list_users(
|
||||||
|
&self,
|
||||||
|
filters: Option<UserRequestFilter>,
|
||||||
|
get_groups: bool,
|
||||||
|
) -> Result<Vec<UserAndGroups>> {
|
||||||
|
self.list_users(filters, get_groups).await
|
||||||
|
}
|
||||||
|
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
||||||
|
self.list_groups(filters).await
|
||||||
|
}
|
||||||
|
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails> {
|
||||||
|
self.get_group_details(group_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<Handler: BackendHandler> UserWriteableBackendHandler for Handler {
|
||||||
|
async fn update_user(&self, request: UpdateUserRequest) -> Result<()> {
|
||||||
|
self.update_user(request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl<Handler: BackendHandler> AdminBackendHandler for Handler {
|
||||||
|
async fn create_user(&self, request: CreateUserRequest) -> Result<()> {
|
||||||
|
self.create_user(request).await
|
||||||
|
}
|
||||||
|
async fn delete_user(&self, user_id: &UserId) -> Result<()> {
|
||||||
|
self.delete_user(user_id).await
|
||||||
|
}
|
||||||
|
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
|
||||||
|
self.add_user_to_group(user_id, group_id).await
|
||||||
|
}
|
||||||
|
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
|
||||||
|
self.remove_user_from_group(user_id, group_id).await
|
||||||
|
}
|
||||||
|
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()> {
|
||||||
|
self.update_group(request).await
|
||||||
|
}
|
||||||
|
async fn create_group(&self, group_name: &str) -> Result<GroupId> {
|
||||||
|
self.create_group(group_name).await
|
||||||
|
}
|
||||||
|
async fn delete_group(&self, group_id: GroupId) -> Result<()> {
|
||||||
|
self.delete_group(group_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AccessControlledBackendHandler<Handler> {
|
||||||
|
handler: Handler,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Handler: Clone> Clone for AccessControlledBackendHandler<Handler> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
handler: self.handler.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Handler> AccessControlledBackendHandler<Handler> {
|
||||||
|
pub fn unsafe_get_handler(&self) -> &Handler {
|
||||||
|
&self.handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
|
||||||
|
pub fn new(handler: Handler) -> Self {
|
||||||
|
Self { handler }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_admin_handler(
|
||||||
|
&self,
|
||||||
|
validation_result: &ValidationResults,
|
||||||
|
) -> Option<&impl AdminBackendHandler> {
|
||||||
|
validation_result.is_admin().then_some(&self.handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_readonly_handler(
|
||||||
|
&self,
|
||||||
|
validation_result: &ValidationResults,
|
||||||
|
) -> Option<&impl ReadonlyBackendHandler> {
|
||||||
|
validation_result.can_read_all().then_some(&self.handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_writeable_handler(
|
||||||
|
&self,
|
||||||
|
validation_result: &ValidationResults,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Option<&impl UserWriteableBackendHandler> {
|
||||||
|
validation_result
|
||||||
|
.can_write(user_id)
|
||||||
|
.then_some(&self.handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_readable_handler(
|
||||||
|
&self,
|
||||||
|
validation_result: &ValidationResults,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Option<&impl UserReadableBackendHandler> {
|
||||||
|
validation_result.can_read(user_id).then_some(&self.handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_user_restricted_lister_handler(
|
||||||
|
&self,
|
||||||
|
validation_result: &ValidationResults,
|
||||||
|
) -> UserRestrictedListerBackendHandler<'_, Handler> {
|
||||||
|
UserRestrictedListerBackendHandler {
|
||||||
|
handler: &self.handler,
|
||||||
|
user_filter: if validation_result.can_read_all() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
info!("Unprivileged search, limiting results");
|
||||||
|
Some(validation_result.user.clone())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_permissions_for_user(&self, user_id: UserId) -> Result<ValidationResults> {
|
||||||
|
let user_groups = self.handler.get_user_groups(&user_id).await?;
|
||||||
|
Ok(self.get_permissions_from_groups(user_id, user_groups.iter().map(|g| &g.display_name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_permissions_from_groups<'a, Groups: Iterator<Item = &'a String> + Clone + 'a>(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
groups: Groups,
|
||||||
|
) -> ValidationResults {
|
||||||
|
let is_in_group = |name| groups.clone().any(|g| g == name);
|
||||||
|
ValidationResults {
|
||||||
|
user: user_id,
|
||||||
|
permission: if is_in_group("lldap_admin") {
|
||||||
|
Permission::Admin
|
||||||
|
} else if is_in_group("lldap_password_manager") {
|
||||||
|
Permission::PasswordManager
|
||||||
|
} else if is_in_group("lldap_strict_readonly") {
|
||||||
|
Permission::Readonly
|
||||||
|
} else {
|
||||||
|
Permission::Regular
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserRestrictedListerBackendHandler<'a, Handler> {
|
||||||
|
handler: &'a Handler,
|
||||||
|
pub user_filter: Option<UserId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'a, Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
|
||||||
|
for UserRestrictedListerBackendHandler<'a, Handler>
|
||||||
|
{
|
||||||
|
async fn list_users(
|
||||||
|
&self,
|
||||||
|
filters: Option<UserRequestFilter>,
|
||||||
|
get_groups: bool,
|
||||||
|
) -> Result<Vec<UserAndGroups>> {
|
||||||
|
let user_filter = self
|
||||||
|
.user_filter
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| UserRequestFilter::UserId(u.clone()));
|
||||||
|
let filters = match (filters, user_filter) {
|
||||||
|
(None, None) => None,
|
||||||
|
(None, u) => u,
|
||||||
|
(f, None) => f,
|
||||||
|
(Some(f), Some(u)) => Some(UserRequestFilter::And(vec![f, u])),
|
||||||
|
};
|
||||||
|
self.handler.list_users(filters, get_groups).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'a, Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
|
||||||
|
for UserRestrictedListerBackendHandler<'a, Handler>
|
||||||
|
{
|
||||||
|
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
||||||
|
let group_filter = self
|
||||||
|
.user_filter
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| GroupRequestFilter::Member(u.clone()));
|
||||||
|
let filters = match (filters, group_filter) {
|
||||||
|
(None, None) => None,
|
||||||
|
(None, u) => u,
|
||||||
|
(f, None) => f,
|
||||||
|
(Some(f), Some(u)) => Some(GroupRequestFilter::And(vec![f, u])),
|
||||||
|
};
|
||||||
|
self.handler.list_groups(filters).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserAndGroupListerBackendHandler:
|
||||||
|
UserListerBackendHandler + GroupListerBackendHandler
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'a, Handler: GroupListerBackendHandler + UserListerBackendHandler + Sync>
|
||||||
|
UserAndGroupListerBackendHandler for UserRestrictedListerBackendHandler<'a, Handler>
|
||||||
|
{
|
||||||
|
}
|
|
@ -30,6 +30,7 @@ use crate::{
|
||||||
types::{GroupDetails, UserColumn, UserId},
|
types::{GroupDetails, UserColumn, UserId},
|
||||||
},
|
},
|
||||||
infra::{
|
infra::{
|
||||||
|
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler, ValidationResults},
|
||||||
tcp_backend_handler::*,
|
tcp_backend_handler::*,
|
||||||
tcp_server::{error_to_http_response, AppState, TcpError, TcpResult},
|
tcp_server::{error_to_http_response, AppState, TcpError, TcpResult},
|
||||||
},
|
},
|
||||||
|
@ -87,11 +88,10 @@ async fn get_refresh<Backend>(
|
||||||
where
|
where
|
||||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||||
{
|
{
|
||||||
let backend_handler = &data.backend_handler;
|
|
||||||
let jwt_key = &data.jwt_key;
|
let jwt_key = &data.jwt_key;
|
||||||
let (refresh_token_hash, user) = get_refresh_token(request)?;
|
let (refresh_token_hash, user) = get_refresh_token(request)?;
|
||||||
let found = data
|
let found = data
|
||||||
.backend_handler
|
.get_tcp_handler()
|
||||||
.check_token(refresh_token_hash, &user)
|
.check_token(refresh_token_hash, &user)
|
||||||
.await?;
|
.await?;
|
||||||
if !found {
|
if !found {
|
||||||
|
@ -99,7 +99,8 @@ where
|
||||||
"Invalid refresh token".to_string(),
|
"Invalid refresh token".to_string(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(backend_handler
|
Ok(data
|
||||||
|
.get_readonly_handler()
|
||||||
.get_user_groups(&user)
|
.get_user_groups(&user)
|
||||||
.await
|
.await
|
||||||
.map(|groups| create_jwt(jwt_key, user.to_string(), groups))
|
.map(|groups| create_jwt(jwt_key, user.to_string(), groups))
|
||||||
|
@ -145,7 +146,7 @@ where
|
||||||
.get("user_id")
|
.get("user_id")
|
||||||
.ok_or_else(|| TcpError::BadRequest("Missing user ID".to_string()))?;
|
.ok_or_else(|| TcpError::BadRequest("Missing user ID".to_string()))?;
|
||||||
let user_results = data
|
let user_results = data
|
||||||
.backend_handler
|
.get_readonly_handler()
|
||||||
.list_users(
|
.list_users(
|
||||||
Some(UserRequestFilter::Or(vec![
|
Some(UserRequestFilter::Or(vec![
|
||||||
UserRequestFilter::UserId(UserId::new(user_string)),
|
UserRequestFilter::UserId(UserId::new(user_string)),
|
||||||
|
@ -163,7 +164,7 @@ where
|
||||||
}
|
}
|
||||||
let user = &user_results[0].user;
|
let user = &user_results[0].user;
|
||||||
let token = match data
|
let token = match data
|
||||||
.backend_handler
|
.get_tcp_handler()
|
||||||
.start_password_reset(&user.user_id)
|
.start_password_reset(&user.user_id)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
|
@ -214,13 +215,17 @@ where
|
||||||
let token = request
|
let token = request
|
||||||
.match_info()
|
.match_info()
|
||||||
.get("token")
|
.get("token")
|
||||||
.ok_or_else(|| TcpError::BadRequest("Missing reset token".to_string()))?;
|
.ok_or_else(|| TcpError::BadRequest("Missing reset token".to_owned()))?;
|
||||||
let user_id = data
|
let user_id = data
|
||||||
.backend_handler
|
.get_tcp_handler()
|
||||||
.get_user_id_for_password_reset_token(token)
|
.get_user_id_for_password_reset_token(token)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
debug!("Reset token error: {e:#}");
|
||||||
|
TcpError::NotFoundError("Wrong or expired reset token".to_owned())
|
||||||
|
})?;
|
||||||
let _ = data
|
let _ = data
|
||||||
.backend_handler
|
.get_tcp_handler()
|
||||||
.delete_password_reset_token(token)
|
.delete_password_reset_token(token)
|
||||||
.await;
|
.await;
|
||||||
let groups = HashSet::new();
|
let groups = HashSet::new();
|
||||||
|
@ -262,10 +267,10 @@ where
|
||||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||||
{
|
{
|
||||||
let (refresh_token_hash, user) = get_refresh_token(request)?;
|
let (refresh_token_hash, user) = get_refresh_token(request)?;
|
||||||
data.backend_handler
|
data.get_tcp_handler()
|
||||||
.delete_refresh_token(refresh_token_hash)
|
.delete_refresh_token(refresh_token_hash)
|
||||||
.await?;
|
.await?;
|
||||||
let new_blacklisted_jwts = data.backend_handler.blacklist_jwts(&user).await?;
|
let new_blacklisted_jwts = data.get_tcp_handler().blacklist_jwts(&user).await?;
|
||||||
let mut jwt_blacklist = data.jwt_blacklist.write().unwrap();
|
let mut jwt_blacklist = data.jwt_blacklist.write().unwrap();
|
||||||
for jwt in new_blacklisted_jwts {
|
for jwt in new_blacklisted_jwts {
|
||||||
jwt_blacklist.insert(jwt);
|
jwt_blacklist.insert(jwt);
|
||||||
|
@ -316,7 +321,7 @@ async fn opaque_login_start<Backend>(
|
||||||
where
|
where
|
||||||
Backend: OpaqueHandler + 'static,
|
Backend: OpaqueHandler + 'static,
|
||||||
{
|
{
|
||||||
data.backend_handler
|
data.get_opaque_handler()
|
||||||
.login_start(request.into_inner())
|
.login_start(request.into_inner())
|
||||||
.await
|
.await
|
||||||
.map(|res| ApiResult::Left(web::Json(res)))
|
.map(|res| ApiResult::Left(web::Json(res)))
|
||||||
|
@ -333,8 +338,8 @@ where
|
||||||
{
|
{
|
||||||
// The authentication was successful, we need to fetch the groups to create the JWT
|
// The authentication was successful, we need to fetch the groups to create the JWT
|
||||||
// token.
|
// token.
|
||||||
let groups = data.backend_handler.get_user_groups(name).await?;
|
let groups = data.get_readonly_handler().get_user_groups(name).await?;
|
||||||
let (refresh_token, max_age) = data.backend_handler.create_refresh_token(name).await?;
|
let (refresh_token, max_age) = data.get_tcp_handler().create_refresh_token(name).await?;
|
||||||
let token = create_jwt(&data.jwt_key, name.to_string(), groups);
|
let token = create_jwt(&data.jwt_key, name.to_string(), groups);
|
||||||
let refresh_token_plus_name = refresh_token + "+" + name.as_str();
|
let refresh_token_plus_name = refresh_token + "+" + name.as_str();
|
||||||
|
|
||||||
|
@ -370,7 +375,7 @@ where
|
||||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||||
{
|
{
|
||||||
let name = data
|
let name = data
|
||||||
.backend_handler
|
.get_opaque_handler()
|
||||||
.login_finish(request.into_inner())
|
.login_finish(request.into_inner())
|
||||||
.await?;
|
.await?;
|
||||||
get_login_successful_response(&data, &name).await
|
get_login_successful_response(&data, &name).await
|
||||||
|
@ -401,7 +406,7 @@ where
|
||||||
name: user_id.clone(),
|
name: user_id.clone(),
|
||||||
password: request.password.clone(),
|
password: request.password.clone(),
|
||||||
};
|
};
|
||||||
data.backend_handler.bind(bind_request).await?;
|
data.get_login_handler().bind(bind_request).await?;
|
||||||
get_login_successful_response(&data, &user_id).await
|
get_login_successful_response(&data, &user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -427,7 +432,7 @@ where
|
||||||
{
|
{
|
||||||
let name = request.name.clone();
|
let name = request.name.clone();
|
||||||
debug!(%name);
|
debug!(%name);
|
||||||
data.backend_handler.bind(request.into_inner()).await?;
|
data.get_login_handler().bind(request.into_inner()).await?;
|
||||||
get_login_successful_response(&data, &name).await
|
get_login_successful_response(&data, &name).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -446,14 +451,15 @@ where
|
||||||
#[instrument(skip_all, level = "debug")]
|
#[instrument(skip_all, level = "debug")]
|
||||||
async fn opaque_register_start<Backend>(
|
async fn opaque_register_start<Backend>(
|
||||||
request: actix_web::HttpRequest,
|
request: actix_web::HttpRequest,
|
||||||
mut payload: actix_web::web::Payload,
|
payload: actix_web::web::Payload,
|
||||||
data: web::Data<AppState<Backend>>,
|
data: web::Data<AppState<Backend>>,
|
||||||
) -> TcpResult<registration::ServerRegistrationStartResponse>
|
) -> TcpResult<registration::ServerRegistrationStartResponse>
|
||||||
where
|
where
|
||||||
Backend: BackendHandler + OpaqueHandler + 'static,
|
Backend: BackendHandler + OpaqueHandler + 'static,
|
||||||
{
|
{
|
||||||
use actix_web::FromRequest;
|
use actix_web::FromRequest;
|
||||||
let validation_result = BearerAuth::from_request(&request, &mut payload.0)
|
let inner_payload = &mut payload.into_inner();
|
||||||
|
let validation_result = BearerAuth::from_request(&request, inner_payload)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
|
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
|
||||||
|
@ -463,14 +469,14 @@ where
|
||||||
let registration_start_request =
|
let registration_start_request =
|
||||||
web::Json::<registration::ClientRegistrationStartRequest>::from_request(
|
web::Json::<registration::ClientRegistrationStartRequest>::from_request(
|
||||||
&request,
|
&request,
|
||||||
&mut payload.0,
|
inner_payload,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| TcpError::BadRequest(format!("{:#?}", e)))?
|
.map_err(|e| TcpError::BadRequest(format!("{:#?}", e)))?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
let user_id = UserId::new(®istration_start_request.username);
|
let user_id = UserId::new(®istration_start_request.username);
|
||||||
let user_is_admin = data
|
let user_is_admin = data
|
||||||
.backend_handler
|
.get_readonly_handler()
|
||||||
.get_user_groups(&user_id)
|
.get_user_groups(&user_id)
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -481,7 +487,7 @@ where
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(data
|
Ok(data
|
||||||
.backend_handler
|
.get_opaque_handler()
|
||||||
.registration_start(registration_start_request)
|
.registration_start(registration_start_request)
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
@ -508,7 +514,7 @@ async fn opaque_register_finish<Backend>(
|
||||||
where
|
where
|
||||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||||
{
|
{
|
||||||
data.backend_handler
|
data.get_opaque_handler()
|
||||||
.registration_finish(request.into_inner())
|
.registration_finish(request.into_inner())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(HttpResponse::Ok().finish())
|
Ok(HttpResponse::Ok().finish())
|
||||||
|
@ -582,64 +588,8 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
pub enum Permission {
|
|
||||||
Admin,
|
|
||||||
PasswordManager,
|
|
||||||
Readonly,
|
|
||||||
Regular,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ValidationResults {
|
|
||||||
pub user: UserId,
|
|
||||||
pub permission: Permission,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ValidationResults {
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn admin() -> Self {
|
|
||||||
Self {
|
|
||||||
user: UserId::new("admin"),
|
|
||||||
permission: Permission::Admin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_admin(&self) -> bool {
|
|
||||||
self.permission == Permission::Admin
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_admin_or_readonly(&self) -> bool {
|
|
||||||
self.permission == Permission::Admin
|
|
||||||
|| self.permission == Permission::Readonly
|
|
||||||
|| self.permission == Permission::PasswordManager
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_read(&self, user: &UserId) -> bool {
|
|
||||||
self.permission == Permission::Admin
|
|
||||||
|| self.permission == Permission::PasswordManager
|
|
||||||
|| self.permission == Permission::Readonly
|
|
||||||
|| &self.user == user
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
|
|
||||||
self.permission == Permission::Admin
|
|
||||||
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|
|
||||||
|| &self.user == user
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_write(&self, user: &UserId) -> bool {
|
|
||||||
self.permission == Permission::Admin || &self.user == user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all, level = "debug", err, ret)]
|
#[instrument(skip_all, level = "debug", err, ret)]
|
||||||
pub(crate) fn check_if_token_is_valid<Backend>(
|
pub(crate) fn check_if_token_is_valid<Backend: BackendHandler>(
|
||||||
state: &AppState<Backend>,
|
state: &AppState<Backend>,
|
||||||
token_str: &str,
|
token_str: &str,
|
||||||
) -> Result<ValidationResults, actix_web::Error> {
|
) -> Result<ValidationResults, actix_web::Error> {
|
||||||
|
@ -662,22 +612,13 @@ pub(crate) fn check_if_token_is_valid<Backend>(
|
||||||
if state.jwt_blacklist.read().unwrap().contains(&jwt_hash) {
|
if state.jwt_blacklist.read().unwrap().contains(&jwt_hash) {
|
||||||
return Err(ErrorUnauthorized("JWT was logged out"));
|
return Err(ErrorUnauthorized("JWT was logged out"));
|
||||||
}
|
}
|
||||||
let is_in_group = |name| token.claims().groups.contains(name);
|
Ok(state.backend_handler.get_permissions_from_groups(
|
||||||
Ok(ValidationResults {
|
UserId::new(&token.claims().user),
|
||||||
user: UserId::new(&token.claims().user),
|
token.claims().groups.iter(),
|
||||||
permission: if is_in_group("lldap_admin") {
|
))
|
||||||
Permission::Admin
|
|
||||||
} else if is_in_group("lldap_password_manager") {
|
|
||||||
Permission::PasswordManager
|
|
||||||
} else if is_in_group("lldap_strict_readonly") {
|
|
||||||
Permission::Readonly
|
|
||||||
} else {
|
|
||||||
Permission::Regular
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig)
|
pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig, enable_password_reset: bool)
|
||||||
where
|
where
|
||||||
Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static,
|
Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static,
|
||||||
{
|
{
|
||||||
|
@ -694,14 +635,6 @@ where
|
||||||
web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)),
|
web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)),
|
||||||
)
|
)
|
||||||
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
|
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
|
||||||
.service(
|
|
||||||
web::resource("/reset/step1/{user_id}")
|
|
||||||
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
|
|
||||||
)
|
|
||||||
.service(
|
|
||||||
web::resource("/reset/step2/{token}")
|
|
||||||
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
|
|
||||||
)
|
|
||||||
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
|
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
|
||||||
.service(
|
.service(
|
||||||
web::scope("/opaque/register")
|
web::scope("/opaque/register")
|
||||||
|
@ -715,4 +648,14 @@ where
|
||||||
.route(web::post().to(opaque_register_finish_handler::<Backend>)),
|
.route(web::post().to(opaque_register_finish_handler::<Backend>)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if enable_password_reset {
|
||||||
|
cfg.service(
|
||||||
|
web::resource("/reset/step1/{user_id}")
|
||||||
|
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/reset/step2/{token}")
|
||||||
|
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use clap::Parser;
|
use clap::{builder::EnumValueParser, Parser};
|
||||||
use lettre::message::Mailbox;
|
use lettre::message::Mailbox;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
@ -26,6 +26,9 @@ pub enum Command {
|
||||||
/// Send a test email.
|
/// Send a test email.
|
||||||
#[clap(name = "send_test_email")]
|
#[clap(name = "send_test_email")]
|
||||||
SendTestEmail(TestEmailOpts),
|
SendTestEmail(TestEmailOpts),
|
||||||
|
/// Create database schema.
|
||||||
|
#[clap(name = "create_schema")]
|
||||||
|
CreateSchema(RunOpts),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Parser, Clone)]
|
#[derive(Debug, Parser, Clone)]
|
||||||
|
@ -74,6 +77,10 @@ pub struct RunOpts {
|
||||||
#[clap(long, env = "LLDAP_HTTP_URL")]
|
#[clap(long, env = "LLDAP_HTTP_URL")]
|
||||||
pub http_url: Option<String>,
|
pub http_url: Option<String>,
|
||||||
|
|
||||||
|
/// Database connection URL
|
||||||
|
#[clap(short, long, env = "LLDAP_DATABASE_URL")]
|
||||||
|
pub database_url: Option<String>,
|
||||||
|
|
||||||
#[clap(flatten)]
|
#[clap(flatten)]
|
||||||
pub smtp_opts: SmtpOpts,
|
pub smtp_opts: SmtpOpts,
|
||||||
|
|
||||||
|
@ -95,7 +102,7 @@ pub struct TestEmailOpts {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Parser, Clone)]
|
#[derive(Debug, Parser, Clone)]
|
||||||
#[clap(next_help_heading = Some("LDAPS"), setting = clap::AppSettings::DeriveDisplayOrder)]
|
#[clap(next_help_heading = Some("LDAPS"))]
|
||||||
pub struct LdapsOpts {
|
pub struct LdapsOpts {
|
||||||
/// Enable LDAPS. Default: false.
|
/// Enable LDAPS. Default: false.
|
||||||
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__ENABLED")]
|
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__ENABLED")]
|
||||||
|
@ -114,16 +121,16 @@ pub struct LdapsOpts {
|
||||||
pub ldaps_key_file: Option<String>,
|
pub ldaps_key_file: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
clap::arg_enum! {
|
#[derive(Clone, Debug, Deserialize, Serialize, clap::ValueEnum)]
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[serde(rename_all = "UPPERCASE")]
|
||||||
pub enum SmtpEncryption {
|
pub enum SmtpEncryption {
|
||||||
TLS,
|
None,
|
||||||
STARTTLS,
|
Tls,
|
||||||
}
|
StartTls,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Parser, Clone)]
|
#[derive(Debug, Parser, Clone)]
|
||||||
#[clap(next_help_heading = Some("SMTP"), setting = clap::AppSettings::DeriveDisplayOrder)]
|
#[clap(next_help_heading = Some("SMTP"))]
|
||||||
pub struct SmtpOpts {
|
pub struct SmtpOpts {
|
||||||
/// Sender email address.
|
/// Sender email address.
|
||||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")]
|
#[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")]
|
||||||
|
@ -150,10 +157,10 @@ pub struct SmtpOpts {
|
||||||
pub smtp_password: Option<String>,
|
pub smtp_password: Option<String>,
|
||||||
|
|
||||||
/// Whether TLS should be used to connect to SMTP.
|
/// Whether TLS should be used to connect to SMTP.
|
||||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__TLS_REQUIRED", setting=clap::ArgSettings::Hidden)]
|
#[clap(long, env = "LLDAP_SMTP_OPTIONS__TLS_REQUIRED", hide = true)]
|
||||||
pub smtp_tls_required: Option<bool>,
|
pub smtp_tls_required: Option<bool>,
|
||||||
|
|
||||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__ENCRYPTION", possible_values = SmtpEncryption::variants(), case_insensitive = true)]
|
#[clap(long, env = "LLDAP_SMTP_OPTIONS__ENCRYPTION", value_parser = EnumValueParser::<SmtpEncryption>::new(), ignore_case = true)]
|
||||||
pub smtp_encryption: Option<SmtpEncryption>,
|
pub smtp_encryption: Option<SmtpEncryption>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,7 @@ pub struct MailOptions {
|
||||||
pub user: String,
|
pub user: String,
|
||||||
#[builder(default = r#"SecUtf8::from("")"#)]
|
#[builder(default = r#"SecUtf8::from("")"#)]
|
||||||
pub password: SecUtf8,
|
pub password: SecUtf8,
|
||||||
#[builder(default = "SmtpEncryption::TLS")]
|
#[builder(default = "SmtpEncryption::Tls")]
|
||||||
pub smtp_encryption: SmtpEncryption,
|
pub smtp_encryption: SmtpEncryption,
|
||||||
/// Deprecated.
|
/// Deprecated.
|
||||||
#[builder(default = "None")]
|
#[builder(default = "None")]
|
||||||
|
@ -209,6 +209,10 @@ impl ConfigOverrider for RunOpts {
|
||||||
if let Some(url) = self.http_url.as_ref() {
|
if let Some(url) = self.http_url.as_ref() {
|
||||||
config.http_url = url.to_string();
|
config.http_url = url.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(database_url) = self.database_url.as_ref() {
|
||||||
|
config.database_url = database_url.to_string();
|
||||||
|
}
|
||||||
self.smtp_opts.override_config(config);
|
self.smtp_opts.override_config(config);
|
||||||
self.ldaps_opts.override_config(config);
|
self.ldaps_opts.override_config(config);
|
||||||
}
|
}
|
||||||
|
@ -266,6 +270,9 @@ impl ConfigOverrider for SmtpOpts {
|
||||||
if let Some(password) = &self.smtp_password {
|
if let Some(password) = &self.smtp_password {
|
||||||
config.smtp_options.password = SecUtf8::from(password.clone());
|
config.smtp_options.password = SecUtf8::from(password.clone());
|
||||||
}
|
}
|
||||||
|
if let Some(smtp_encryption) = &self.smtp_encryption {
|
||||||
|
config.smtp_options.smtp_encryption = smtp_encryption.clone();
|
||||||
|
}
|
||||||
if let Some(tls_required) = self.smtp_tls_required {
|
if let Some(tls_required) = self.smtp_tls_required {
|
||||||
config.smtp_options.tls_required = Some(tls_required);
|
config.smtp_options.tls_required = Some(tls_required);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,29 +1,84 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::handler::BackendHandler,
|
domain::{handler::BackendHandler, types::UserId},
|
||||||
infra::{
|
infra::{
|
||||||
auth_service::{check_if_token_is_valid, ValidationResults},
|
access_control::{
|
||||||
|
AccessControlledBackendHandler, AdminBackendHandler, ReadonlyBackendHandler,
|
||||||
|
UserReadableBackendHandler, UserWriteableBackendHandler, ValidationResults,
|
||||||
|
},
|
||||||
|
auth_service::check_if_token_is_valid,
|
||||||
cli::ExportGraphQLSchemaOpts,
|
cli::ExportGraphQLSchemaOpts,
|
||||||
|
graphql::{mutation::Mutation, query::Query},
|
||||||
tcp_server::AppState,
|
tcp_server::AppState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use actix_web::{web, Error, HttpResponse};
|
use actix_web::FromRequest;
|
||||||
|
use actix_web::HttpMessage;
|
||||||
|
use actix_web::{error::JsonPayloadError, web, Error, HttpRequest, HttpResponse};
|
||||||
use actix_web_httpauth::extractors::bearer::BearerAuth;
|
use actix_web_httpauth::extractors::bearer::BearerAuth;
|
||||||
use juniper::{EmptySubscription, RootNode};
|
use juniper::{
|
||||||
use juniper_actix::{graphiql_handler, graphql_handler, playground_handler};
|
http::{
|
||||||
|
graphiql::graphiql_source, playground::playground_source, GraphQLBatchRequest,
|
||||||
use super::{mutation::Mutation, query::Query};
|
GraphQLRequest,
|
||||||
|
},
|
||||||
|
EmptySubscription, FieldError, RootNode, ScalarValue,
|
||||||
|
};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
pub struct Context<Handler: BackendHandler> {
|
pub struct Context<Handler: BackendHandler> {
|
||||||
pub handler: Box<Handler>,
|
pub handler: AccessControlledBackendHandler<Handler>,
|
||||||
pub validation_result: ValidationResults,
|
pub validation_result: ValidationResults,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn field_error_callback<'a>(
|
||||||
|
span: &'a tracing::Span,
|
||||||
|
error_message: &'a str,
|
||||||
|
) -> impl 'a + FnOnce() -> FieldError {
|
||||||
|
move || {
|
||||||
|
span.in_scope(|| debug!("Unauthorized"));
|
||||||
|
FieldError::from(error_message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Handler: BackendHandler> Context<Handler> {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn new_for_tests(handler: Handler, validation_result: ValidationResults) -> Self {
|
||||||
|
Self {
|
||||||
|
handler: AccessControlledBackendHandler::new(handler),
|
||||||
|
validation_result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_admin_handler(&self) -> Option<&impl AdminBackendHandler> {
|
||||||
|
self.handler.get_admin_handler(&self.validation_result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_readonly_handler(&self) -> Option<&impl ReadonlyBackendHandler> {
|
||||||
|
self.handler.get_readonly_handler(&self.validation_result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_writeable_handler(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Option<&impl UserWriteableBackendHandler> {
|
||||||
|
self.handler
|
||||||
|
.get_writeable_handler(&self.validation_result, user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_readable_handler(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Option<&impl UserReadableBackendHandler> {
|
||||||
|
self.handler
|
||||||
|
.get_readable_handler(&self.validation_result, user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<Handler: BackendHandler> juniper::Context for Context<Handler> {}
|
impl<Handler: BackendHandler> juniper::Context for Context<Handler> {}
|
||||||
|
|
||||||
type Schema<Handler> =
|
type Schema<Handler> =
|
||||||
RootNode<'static, Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>;
|
RootNode<'static, Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>;
|
||||||
|
|
||||||
fn schema<Handler: BackendHandler + Sync>() -> Schema<Handler> {
|
fn schema<Handler: BackendHandler>() -> Schema<Handler> {
|
||||||
Schema::new(
|
Schema::new(
|
||||||
Query::<Handler>::new(),
|
Query::<Handler>::new(),
|
||||||
Mutation::<Handler>::new(),
|
Mutation::<Handler>::new(),
|
||||||
|
@ -52,30 +107,134 @@ pub fn export_schema(opts: ExportGraphQLSchemaOpts) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn graphiql_route() -> Result<HttpResponse, Error> {
|
async fn graphiql_route() -> Result<HttpResponse, Error> {
|
||||||
graphiql_handler("/api/graphql", None).await
|
let html = graphiql_source("/api/graphql", None);
|
||||||
|
Ok(HttpResponse::Ok()
|
||||||
|
.content_type("text/html; charset=utf-8")
|
||||||
|
.body(html))
|
||||||
}
|
}
|
||||||
async fn playground_route() -> Result<HttpResponse, Error> {
|
async fn playground_route() -> Result<HttpResponse, Error> {
|
||||||
playground_handler("/api/graphql", None).await
|
let html = playground_source("/api/graphql", None);
|
||||||
|
Ok(HttpResponse::Ok()
|
||||||
|
.content_type("text/html; charset=utf-8")
|
||||||
|
.body(html))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn graphql_route<Handler: BackendHandler + Sync>(
|
#[derive(serde::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
struct GetGraphQLRequest {
|
||||||
|
query: String,
|
||||||
|
#[serde(rename = "operationName")]
|
||||||
|
operation_name: Option<String>,
|
||||||
|
variables: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> From<GetGraphQLRequest> for GraphQLRequest<S>
|
||||||
|
where
|
||||||
|
S: ScalarValue,
|
||||||
|
{
|
||||||
|
fn from(get_req: GetGraphQLRequest) -> Self {
|
||||||
|
let GetGraphQLRequest {
|
||||||
|
query,
|
||||||
|
operation_name,
|
||||||
|
variables,
|
||||||
|
} = get_req;
|
||||||
|
let variables = variables.map(|s| serde_json::from_str(&s).unwrap());
|
||||||
|
Self::new(query, operation_name, variables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actix GraphQL Handler for GET requests
|
||||||
|
pub async fn get_graphql_handler<Query, Mutation, Subscription, CtxT, S>(
|
||||||
|
schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
|
||||||
|
context: &CtxT,
|
||||||
|
req: HttpRequest,
|
||||||
|
) -> Result<HttpResponse, Error>
|
||||||
|
where
|
||||||
|
Query: juniper::GraphQLTypeAsync<S, Context = CtxT>,
|
||||||
|
Query::TypeInfo: Sync,
|
||||||
|
Mutation: juniper::GraphQLTypeAsync<S, Context = CtxT>,
|
||||||
|
Mutation::TypeInfo: Sync,
|
||||||
|
Subscription: juniper::GraphQLSubscriptionType<S, Context = CtxT>,
|
||||||
|
Subscription::TypeInfo: Sync,
|
||||||
|
CtxT: Sync,
|
||||||
|
S: ScalarValue + Send + Sync,
|
||||||
|
{
|
||||||
|
let get_req = web::Query::<GetGraphQLRequest>::from_query(req.query_string())?;
|
||||||
|
let req = GraphQLRequest::from(get_req.into_inner());
|
||||||
|
let gql_response = req.execute(schema, context).await;
|
||||||
|
let body_response = serde_json::to_string(&gql_response)?;
|
||||||
|
let mut response = match gql_response.is_ok() {
|
||||||
|
true => HttpResponse::Ok(),
|
||||||
|
false => HttpResponse::BadRequest(),
|
||||||
|
};
|
||||||
|
Ok(response
|
||||||
|
.content_type("application/json")
|
||||||
|
.body(body_response))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actix GraphQL Handler for POST requests
|
||||||
|
pub async fn post_graphql_handler<Query, Mutation, Subscription, CtxT, S>(
|
||||||
|
schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
|
||||||
|
context: &CtxT,
|
||||||
|
req: HttpRequest,
|
||||||
|
mut payload: actix_http::Payload,
|
||||||
|
) -> Result<HttpResponse, Error>
|
||||||
|
where
|
||||||
|
Query: juniper::GraphQLTypeAsync<S, Context = CtxT>,
|
||||||
|
Query::TypeInfo: Sync,
|
||||||
|
Mutation: juniper::GraphQLTypeAsync<S, Context = CtxT>,
|
||||||
|
Mutation::TypeInfo: Sync,
|
||||||
|
Subscription: juniper::GraphQLSubscriptionType<S, Context = CtxT>,
|
||||||
|
Subscription::TypeInfo: Sync,
|
||||||
|
CtxT: Sync,
|
||||||
|
S: ScalarValue + Send + Sync,
|
||||||
|
{
|
||||||
|
let req = match req.content_type() {
|
||||||
|
"application/json" => {
|
||||||
|
let body = String::from_request(&req, &mut payload).await?;
|
||||||
|
serde_json::from_str::<GraphQLBatchRequest<S>>(&body)
|
||||||
|
.map_err(JsonPayloadError::Deserialize)
|
||||||
|
}
|
||||||
|
"application/graphql" => {
|
||||||
|
let body = String::from_request(&req, &mut payload).await?;
|
||||||
|
Ok(GraphQLBatchRequest::Single(GraphQLRequest::new(
|
||||||
|
body, None, None,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
_ => Err(JsonPayloadError::ContentType),
|
||||||
|
}?;
|
||||||
|
let gql_batch_response = req.execute(schema, context).await;
|
||||||
|
let gql_response = serde_json::to_string(&gql_batch_response)?;
|
||||||
|
let mut response = match gql_batch_response.is_ok() {
|
||||||
|
true => HttpResponse::Ok(),
|
||||||
|
false => HttpResponse::BadRequest(),
|
||||||
|
};
|
||||||
|
Ok(response.content_type("application/json").body(gql_response))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn graphql_route<Handler: BackendHandler + Clone>(
|
||||||
req: actix_web::HttpRequest,
|
req: actix_web::HttpRequest,
|
||||||
mut payload: actix_web::web::Payload,
|
payload: actix_web::web::Payload,
|
||||||
data: web::Data<AppState<Handler>>,
|
data: web::Data<AppState<Handler>>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
use actix_web::FromRequest;
|
let mut inner_payload = payload.into_inner();
|
||||||
let bearer = BearerAuth::from_request(&req, &mut payload.0).await?;
|
let bearer = BearerAuth::from_request(&req, &mut inner_payload).await?;
|
||||||
let validation_result = check_if_token_is_valid(&data, bearer.token())?;
|
let validation_result = check_if_token_is_valid(&data, bearer.token())?;
|
||||||
let context = Context::<Handler> {
|
let context = Context::<Handler> {
|
||||||
handler: Box::new(data.backend_handler.clone()),
|
handler: data.backend_handler.clone(),
|
||||||
validation_result,
|
validation_result,
|
||||||
};
|
};
|
||||||
graphql_handler(&schema(), &context, req, payload).await
|
let schema = &schema();
|
||||||
|
let context = &context;
|
||||||
|
match *req.method() {
|
||||||
|
actix_http::Method::POST => post_graphql_handler(schema, context, req, inner_payload).await,
|
||||||
|
actix_http::Method::GET => get_graphql_handler(schema, context, req).await,
|
||||||
|
_ => Err(actix_web::error::UrlGenerationError::ResourceNotFound.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn configure_endpoint<Backend>(cfg: &mut web::ServiceConfig)
|
pub fn configure_endpoint<Backend>(cfg: &mut web::ServiceConfig)
|
||||||
where
|
where
|
||||||
Backend: BackendHandler + Sync + 'static,
|
Backend: BackendHandler + Clone + 'static,
|
||||||
{
|
{
|
||||||
let json_config = web::JsonConfig::default()
|
let json_config = web::JsonConfig::default()
|
||||||
.limit(4096)
|
.limit(4096)
|
||||||
|
|
|
@ -1,8 +1,18 @@
|
||||||
use crate::domain::{
|
use crate::{
|
||||||
|
domain::{
|
||||||
handler::{BackendHandler, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest},
|
handler::{BackendHandler, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest},
|
||||||
types::{GroupId, JpegPhoto, UserId},
|
types::{GroupId, JpegPhoto, UserId},
|
||||||
|
},
|
||||||
|
infra::{
|
||||||
|
access_control::{
|
||||||
|
AdminBackendHandler, ReadonlyBackendHandler, UserReadableBackendHandler,
|
||||||
|
UserWriteableBackendHandler,
|
||||||
|
},
|
||||||
|
graphql::api::field_error_callback,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::Context as AnyhowContext;
|
use anyhow::Context as AnyhowContext;
|
||||||
|
use base64::Engine;
|
||||||
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
|
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
|
||||||
use tracing::{debug, debug_span, Instrument};
|
use tracing::{debug, debug_span, Instrument};
|
||||||
|
|
||||||
|
@ -65,30 +75,28 @@ impl Success {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[graphql_object(context = Context<Handler>)]
|
#[graphql_object(context = Context<Handler>)]
|
||||||
impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
impl<Handler: BackendHandler> Mutation<Handler> {
|
||||||
async fn create_user(
|
async fn create_user(
|
||||||
context: &Context<Handler>,
|
context: &Context<Handler>,
|
||||||
user: CreateUserInput,
|
user: CreateUserInput,
|
||||||
) -> FieldResult<super::query::User<Handler>> {
|
) -> FieldResult<super::query::User<Handler>> {
|
||||||
let span = debug_span!("[GraphQL mutation] create_user");
|
let span = debug_span!("[GraphQL mutation] create_user");
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(?user.id);
|
debug!("{:?}", &user.id);
|
||||||
});
|
});
|
||||||
if !context.validation_result.is_admin() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_admin_handler()
|
||||||
return Err("Unauthorized user creation".into());
|
.ok_or_else(field_error_callback(&span, "Unauthorized user creation"))?;
|
||||||
}
|
|
||||||
let user_id = UserId::new(&user.id);
|
let user_id = UserId::new(&user.id);
|
||||||
let avatar = user
|
let avatar = user
|
||||||
.avatar
|
.avatar
|
||||||
.map(base64::decode)
|
.map(|bytes| base64::engine::general_purpose::STANDARD.decode(bytes))
|
||||||
.transpose()
|
.transpose()
|
||||||
.context("Invalid base64 image")?
|
.context("Invalid base64 image")?
|
||||||
.map(JpegPhoto::try_from)
|
.map(JpegPhoto::try_from)
|
||||||
.transpose()
|
.transpose()
|
||||||
.context("Provided image is not a valid JPEG")?;
|
.context("Provided image is not a valid JPEG")?;
|
||||||
context
|
handler
|
||||||
.handler
|
|
||||||
.create_user(CreateUserRequest {
|
.create_user(CreateUserRequest {
|
||||||
user_id: user_id.clone(),
|
user_id: user_id.clone(),
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
@ -99,8 +107,7 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||||
})
|
})
|
||||||
.instrument(span.clone())
|
.instrument(span.clone())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(context
|
Ok(handler
|
||||||
.handler
|
|
||||||
.get_user_details(&user_id)
|
.get_user_details(&user_id)
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await
|
||||||
|
@ -115,13 +122,11 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(?name);
|
debug!(?name);
|
||||||
});
|
});
|
||||||
if !context.validation_result.is_admin() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_admin_handler()
|
||||||
return Err("Unauthorized group creation".into());
|
.ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?;
|
||||||
}
|
let group_id = handler.create_group(&name).await?;
|
||||||
let group_id = context.handler.create_group(&name).await?;
|
Ok(handler
|
||||||
Ok(context
|
|
||||||
.handler
|
|
||||||
.get_group_details(group_id)
|
.get_group_details(group_id)
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await
|
||||||
|
@ -137,20 +142,18 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||||
debug!(?user.id);
|
debug!(?user.id);
|
||||||
});
|
});
|
||||||
let user_id = UserId::new(&user.id);
|
let user_id = UserId::new(&user.id);
|
||||||
if !context.validation_result.can_write(&user_id) {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_writeable_handler(&user_id)
|
||||||
return Err("Unauthorized user update".into());
|
.ok_or_else(field_error_callback(&span, "Unauthorized user update"))?;
|
||||||
}
|
|
||||||
let avatar = user
|
let avatar = user
|
||||||
.avatar
|
.avatar
|
||||||
.map(base64::decode)
|
.map(|bytes| base64::engine::general_purpose::STANDARD.decode(bytes))
|
||||||
.transpose()
|
.transpose()
|
||||||
.context("Invalid base64 image")?
|
.context("Invalid base64 image")?
|
||||||
.map(JpegPhoto::try_from)
|
.map(JpegPhoto::try_from)
|
||||||
.transpose()
|
.transpose()
|
||||||
.context("Provided image is not a valid JPEG")?;
|
.context("Provided image is not a valid JPEG")?;
|
||||||
context
|
handler
|
||||||
.handler
|
|
||||||
.update_user(UpdateUserRequest {
|
.update_user(UpdateUserRequest {
|
||||||
user_id,
|
user_id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
@ -172,16 +175,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(?group.id);
|
debug!(?group.id);
|
||||||
});
|
});
|
||||||
if !context.validation_result.is_admin() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_admin_handler()
|
||||||
return Err("Unauthorized group update".into());
|
.ok_or_else(field_error_callback(&span, "Unauthorized group update"))?;
|
||||||
}
|
|
||||||
if group.id == 1 {
|
if group.id == 1 {
|
||||||
span.in_scope(|| debug!("Cannot change admin group details"));
|
span.in_scope(|| debug!("Cannot change admin group details"));
|
||||||
return Err("Cannot change admin group details".into());
|
return Err("Cannot change admin group details".into());
|
||||||
}
|
}
|
||||||
context
|
handler
|
||||||
.handler
|
|
||||||
.update_group(UpdateGroupRequest {
|
.update_group(UpdateGroupRequest {
|
||||||
group_id: GroupId(group.id),
|
group_id: GroupId(group.id),
|
||||||
display_name: group.display_name,
|
display_name: group.display_name,
|
||||||
|
@ -200,12 +201,13 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(?user_id, ?group_id);
|
debug!(?user_id, ?group_id);
|
||||||
});
|
});
|
||||||
if !context.validation_result.is_admin() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_admin_handler()
|
||||||
return Err("Unauthorized group membership modification".into());
|
.ok_or_else(field_error_callback(
|
||||||
}
|
&span,
|
||||||
context
|
"Unauthorized group membership modification",
|
||||||
.handler
|
))?;
|
||||||
|
handler
|
||||||
.add_user_to_group(&UserId::new(&user_id), GroupId(group_id))
|
.add_user_to_group(&UserId::new(&user_id), GroupId(group_id))
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -221,17 +223,18 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(?user_id, ?group_id);
|
debug!(?user_id, ?group_id);
|
||||||
});
|
});
|
||||||
if !context.validation_result.is_admin() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_admin_handler()
|
||||||
return Err("Unauthorized group membership modification".into());
|
.ok_or_else(field_error_callback(
|
||||||
}
|
&span,
|
||||||
|
"Unauthorized group membership modification",
|
||||||
|
))?;
|
||||||
let user_id = UserId::new(&user_id);
|
let user_id = UserId::new(&user_id);
|
||||||
if context.validation_result.user == user_id && group_id == 1 {
|
if context.validation_result.user == user_id && group_id == 1 {
|
||||||
span.in_scope(|| debug!("Cannot remove admin rights for current user"));
|
span.in_scope(|| debug!("Cannot remove admin rights for current user"));
|
||||||
return Err("Cannot remove admin rights for current user".into());
|
return Err("Cannot remove admin rights for current user".into());
|
||||||
}
|
}
|
||||||
context
|
handler
|
||||||
.handler
|
|
||||||
.remove_user_from_group(&user_id, GroupId(group_id))
|
.remove_user_from_group(&user_id, GroupId(group_id))
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -244,19 +247,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||||
debug!(?user_id);
|
debug!(?user_id);
|
||||||
});
|
});
|
||||||
let user_id = UserId::new(&user_id);
|
let user_id = UserId::new(&user_id);
|
||||||
if !context.validation_result.is_admin() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_admin_handler()
|
||||||
return Err("Unauthorized user deletion".into());
|
.ok_or_else(field_error_callback(&span, "Unauthorized user deletion"))?;
|
||||||
}
|
|
||||||
if context.validation_result.user == user_id {
|
if context.validation_result.user == user_id {
|
||||||
span.in_scope(|| debug!("Cannot delete current user"));
|
span.in_scope(|| debug!("Cannot delete current user"));
|
||||||
return Err("Cannot delete current user".into());
|
return Err("Cannot delete current user".into());
|
||||||
}
|
}
|
||||||
context
|
handler.delete_user(&user_id).instrument(span).await?;
|
||||||
.handler
|
|
||||||
.delete_user(&user_id)
|
|
||||||
.instrument(span)
|
|
||||||
.await?;
|
|
||||||
Ok(Success::new())
|
Ok(Success::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,16 +263,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(?group_id);
|
debug!(?group_id);
|
||||||
});
|
});
|
||||||
if !context.validation_result.is_admin() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_admin_handler()
|
||||||
return Err("Unauthorized group deletion".into());
|
.ok_or_else(field_error_callback(&span, "Unauthorized group deletion"))?;
|
||||||
}
|
|
||||||
if group_id == 1 {
|
if group_id == 1 {
|
||||||
span.in_scope(|| debug!("Cannot delete admin group"));
|
span.in_scope(|| debug!("Cannot delete admin group"));
|
||||||
return Err("Cannot delete admin group".into());
|
return Err("Cannot delete admin group".into());
|
||||||
}
|
}
|
||||||
context
|
handler
|
||||||
.handler
|
|
||||||
.delete_group(GroupId(group_id))
|
.delete_group(GroupId(group_id))
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
use crate::domain::{
|
use crate::{
|
||||||
|
domain::{
|
||||||
handler::BackendHandler,
|
handler::BackendHandler,
|
||||||
ldap::utils::map_user_field,
|
ldap::utils::map_user_field,
|
||||||
types::{GroupDetails, GroupId, UserColumn, UserId},
|
types::{GroupDetails, GroupId, UserColumn, UserId},
|
||||||
|
},
|
||||||
|
infra::{
|
||||||
|
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
|
||||||
|
graphql::api::field_error_callback,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
use chrono::TimeZone;
|
||||||
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
|
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, debug_span, Instrument};
|
use tracing::{debug, debug_span, Instrument};
|
||||||
|
@ -111,7 +118,7 @@ impl<Handler: BackendHandler> Query<Handler> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[graphql_object(context = Context<Handler>)]
|
#[graphql_object(context = Context<Handler>)]
|
||||||
impl<Handler: BackendHandler + Sync> Query<Handler> {
|
impl<Handler: BackendHandler> Query<Handler> {
|
||||||
fn api_version() -> &'static str {
|
fn api_version() -> &'static str {
|
||||||
"1.0"
|
"1.0"
|
||||||
}
|
}
|
||||||
|
@ -122,12 +129,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||||
debug!(?user_id);
|
debug!(?user_id);
|
||||||
});
|
});
|
||||||
let user_id = UserId::new(&user_id);
|
let user_id = UserId::new(&user_id);
|
||||||
if !context.validation_result.can_read(&user_id) {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_readable_handler(&user_id)
|
||||||
return Err("Unauthorized access to user data".into());
|
.ok_or_else(field_error_callback(
|
||||||
}
|
&span,
|
||||||
Ok(context
|
"Unauthorized access to user data",
|
||||||
.handler
|
))?;
|
||||||
|
Ok(handler
|
||||||
.get_user_details(&user_id)
|
.get_user_details(&user_id)
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await
|
||||||
|
@ -142,12 +150,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(?filters);
|
debug!(?filters);
|
||||||
});
|
});
|
||||||
if !context.validation_result.is_admin_or_readonly() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_readonly_handler()
|
||||||
return Err("Unauthorized access to user list".into());
|
.ok_or_else(field_error_callback(
|
||||||
}
|
&span,
|
||||||
Ok(context
|
"Unauthorized access to user list",
|
||||||
.handler
|
))?;
|
||||||
|
Ok(handler
|
||||||
.list_users(filters.map(TryInto::try_into).transpose()?, false)
|
.list_users(filters.map(TryInto::try_into).transpose()?, false)
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await
|
||||||
|
@ -156,12 +165,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||||
|
|
||||||
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
|
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
|
||||||
let span = debug_span!("[GraphQL query] groups");
|
let span = debug_span!("[GraphQL query] groups");
|
||||||
if !context.validation_result.is_admin_or_readonly() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_readonly_handler()
|
||||||
return Err("Unauthorized access to group list".into());
|
.ok_or_else(field_error_callback(
|
||||||
}
|
&span,
|
||||||
Ok(context
|
"Unauthorized access to group list",
|
||||||
.handler
|
))?;
|
||||||
|
Ok(handler
|
||||||
.list_groups(None)
|
.list_groups(None)
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await
|
||||||
|
@ -173,12 +183,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(?group_id);
|
debug!(?group_id);
|
||||||
});
|
});
|
||||||
if !context.validation_result.is_admin_or_readonly() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_readonly_handler()
|
||||||
return Err("Unauthorized access to group data".into());
|
.ok_or_else(field_error_callback(
|
||||||
}
|
&span,
|
||||||
Ok(context
|
"Unauthorized access to group data",
|
||||||
.handler
|
))?;
|
||||||
|
Ok(handler
|
||||||
.get_group_details(GroupId(group_id))
|
.get_group_details(GroupId(group_id))
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await
|
||||||
|
@ -204,7 +215,7 @@ impl<Handler: BackendHandler> Default for User<Handler> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[graphql_object(context = Context<Handler>)]
|
#[graphql_object(context = Context<Handler>)]
|
||||||
impl<Handler: BackendHandler + Sync> User<Handler> {
|
impl<Handler: BackendHandler> User<Handler> {
|
||||||
fn id(&self) -> &str {
|
fn id(&self) -> &str {
|
||||||
self.user.user_id.as_str()
|
self.user.user_id.as_str()
|
||||||
}
|
}
|
||||||
|
@ -230,7 +241,7 @@ impl<Handler: BackendHandler + Sync> User<Handler> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
|
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
|
||||||
self.user.creation_date
|
chrono::Utc.from_utc_datetime(&self.user.creation_date)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uuid(&self) -> &str {
|
fn uuid(&self) -> &str {
|
||||||
|
@ -243,8 +254,10 @@ impl<Handler: BackendHandler + Sync> User<Handler> {
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(user_id = ?self.user.user_id);
|
debug!(user_id = ?self.user.user_id);
|
||||||
});
|
});
|
||||||
Ok(context
|
let handler = context
|
||||||
.handler
|
.get_readable_handler(&self.user.user_id)
|
||||||
|
.expect("We shouldn't be able to get there without readable permission");
|
||||||
|
Ok(handler
|
||||||
.get_user_groups(&self.user.user_id)
|
.get_user_groups(&self.user.user_id)
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await
|
||||||
|
@ -275,14 +288,14 @@ impl<Handler: BackendHandler> From<DomainUserAndGroups> for User<Handler> {
|
||||||
pub struct Group<Handler: BackendHandler> {
|
pub struct Group<Handler: BackendHandler> {
|
||||||
group_id: i32,
|
group_id: i32,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
creation_date: chrono::DateTime<chrono::Utc>,
|
creation_date: chrono::NaiveDateTime,
|
||||||
uuid: String,
|
uuid: String,
|
||||||
members: Option<Vec<String>>,
|
members: Option<Vec<String>>,
|
||||||
_phantom: std::marker::PhantomData<Box<Handler>>,
|
_phantom: std::marker::PhantomData<Box<Handler>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[graphql_object(context = Context<Handler>)]
|
#[graphql_object(context = Context<Handler>)]
|
||||||
impl<Handler: BackendHandler + Sync> Group<Handler> {
|
impl<Handler: BackendHandler> Group<Handler> {
|
||||||
fn id(&self) -> i32 {
|
fn id(&self) -> i32 {
|
||||||
self.group_id
|
self.group_id
|
||||||
}
|
}
|
||||||
|
@ -290,7 +303,7 @@ impl<Handler: BackendHandler + Sync> Group<Handler> {
|
||||||
self.display_name.clone()
|
self.display_name.clone()
|
||||||
}
|
}
|
||||||
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
|
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
|
||||||
self.creation_date
|
chrono::Utc.from_utc_datetime(&self.creation_date)
|
||||||
}
|
}
|
||||||
fn uuid(&self) -> String {
|
fn uuid(&self) -> String {
|
||||||
self.uuid.clone()
|
self.uuid.clone()
|
||||||
|
@ -301,12 +314,13 @@ impl<Handler: BackendHandler + Sync> Group<Handler> {
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(name = %self.display_name);
|
debug!(name = %self.display_name);
|
||||||
});
|
});
|
||||||
if !context.validation_result.is_admin_or_readonly() {
|
let handler = context
|
||||||
span.in_scope(|| debug!("Unauthorized"));
|
.get_readonly_handler()
|
||||||
return Err("Unauthorized access to group data".into());
|
.ok_or_else(field_error_callback(
|
||||||
}
|
&span,
|
||||||
Ok(context
|
"Unauthorized access to group data",
|
||||||
.handler
|
))?;
|
||||||
|
Ok(handler
|
||||||
.list_users(
|
.list_users(
|
||||||
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
|
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
|
||||||
false,
|
false,
|
||||||
|
@ -346,7 +360,9 @@ impl<Handler: BackendHandler> From<DomainGroup> for Group<Handler> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{domain::handler::MockTestBackendHandler, infra::auth_service::ValidationResults};
|
use crate::{
|
||||||
|
domain::handler::MockTestBackendHandler, infra::access_control::ValidationResults,
|
||||||
|
};
|
||||||
use chrono::TimeZone;
|
use chrono::TimeZone;
|
||||||
use juniper::{
|
use juniper::{
|
||||||
execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType,
|
execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType,
|
||||||
|
@ -389,7 +405,7 @@ mod tests {
|
||||||
Ok(DomainUser {
|
Ok(DomainUser {
|
||||||
user_id: UserId::new("bob"),
|
user_id: UserId::new("bob"),
|
||||||
email: "bob@bobbers.on".to_string(),
|
email: "bob@bobbers.on".to_string(),
|
||||||
creation_date: chrono::Utc.timestamp_millis_opt(42).unwrap(),
|
creation_date: chrono::Utc.timestamp_millis_opt(42).unwrap().naive_utc(),
|
||||||
uuid: crate::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
uuid: crate::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
|
@ -398,17 +414,15 @@ mod tests {
|
||||||
groups.insert(GroupDetails {
|
groups.insert(GroupDetails {
|
||||||
group_id: GroupId(3),
|
group_id: GroupId(3),
|
||||||
display_name: "Bobbersons".to_string(),
|
display_name: "Bobbersons".to_string(),
|
||||||
creation_date: chrono::Utc.timestamp_nanos(42),
|
creation_date: chrono::Utc.timestamp_nanos(42).naive_utc(),
|
||||||
uuid: crate::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
uuid: crate::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||||
});
|
});
|
||||||
mock.expect_get_user_groups()
|
mock.expect_get_user_groups()
|
||||||
.with(eq(UserId::new("bob")))
|
.with(eq(UserId::new("bob")))
|
||||||
.return_once(|_| Ok(groups));
|
.return_once(|_| Ok(groups));
|
||||||
|
|
||||||
let context = Context::<MockTestBackendHandler> {
|
let context =
|
||||||
handler: Box::new(mock),
|
Context::<MockTestBackendHandler>::new_for_tests(mock, ValidationResults::admin());
|
||||||
validation_result: ValidationResults::admin(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let schema = schema(Query::<MockTestBackendHandler>::new());
|
let schema = schema(Query::<MockTestBackendHandler>::new());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
@ -485,10 +499,8 @@ mod tests {
|
||||||
])
|
])
|
||||||
});
|
});
|
||||||
|
|
||||||
let context = Context::<MockTestBackendHandler> {
|
let context =
|
||||||
handler: Box::new(mock),
|
Context::<MockTestBackendHandler>::new_for_tests(mock, ValidationResults::admin());
|
||||||
validation_result: ValidationResults::admin(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let schema = schema(Query::<MockTestBackendHandler>::new());
|
let schema = schema(Query::<MockTestBackendHandler>::new());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
@ -99,6 +99,7 @@ fn get_tls_connector() -> Result<RustlsTlsConnector> {
|
||||||
#[instrument(skip_all, level = "info", err)]
|
#[instrument(skip_all, level = "info", err)]
|
||||||
pub async fn check_ldaps(ldaps_options: &LdapsOptions) -> Result<()> {
|
pub async fn check_ldaps(ldaps_options: &LdapsOptions) -> Result<()> {
|
||||||
if !ldaps_options.enabled {
|
if !ldaps_options.enabled {
|
||||||
|
info!("LDAPS not enabled");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let tls_connector = get_tls_connector()?;
|
let tls_connector = get_tls_connector()?;
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
use sea_orm::ConnectionTrait;
|
use sea_orm::{
|
||||||
use sea_query::{ColumnDef, ForeignKey, ForeignKeyAction, Iden, Table};
|
sea_query::{self, ColumnDef, ForeignKey, ForeignKeyAction, Iden, Table},
|
||||||
|
ConnectionTrait,
|
||||||
|
};
|
||||||
|
|
||||||
pub use crate::domain::{sql_migrations::Users, sql_tables::DbConnection};
|
pub use crate::domain::{sql_migrations::Users, sql_tables::DbConnection};
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -3,7 +3,10 @@ use crate::{
|
||||||
handler::{BackendHandler, LoginHandler},
|
handler::{BackendHandler, LoginHandler},
|
||||||
opaque_handler::OpaqueHandler,
|
opaque_handler::OpaqueHandler,
|
||||||
},
|
},
|
||||||
infra::{configuration::Configuration, ldap_handler::LdapHandler},
|
infra::{
|
||||||
|
access_control::AccessControlledBackendHandler, configuration::Configuration,
|
||||||
|
ldap_handler::LdapHandler,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use actix_rt::net::TcpStream;
|
use actix_rt::net::TcpStream;
|
||||||
use actix_server::ServerBuilder;
|
use actix_server::ServerBuilder;
|
||||||
|
@ -64,7 +67,7 @@ async fn handle_ldap_stream<Stream, Backend>(
|
||||||
) -> Result<Stream>
|
) -> Result<Stream>
|
||||||
where
|
where
|
||||||
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
|
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
|
||||||
Stream: tokio::io::AsyncRead + tokio::io::AsyncWrite,
|
Stream: tokio::io::AsyncRead + tokio::io::AsyncWrite + std::marker::Unpin,
|
||||||
{
|
{
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
let (r, w) = tokio::io::split(stream);
|
let (r, w) = tokio::io::split(stream);
|
||||||
|
@ -73,7 +76,7 @@ where
|
||||||
let mut resp = FramedWrite::new(w, LdapCodec);
|
let mut resp = FramedWrite::new(w, LdapCodec);
|
||||||
|
|
||||||
let mut session = LdapHandler::new(
|
let mut session = LdapHandler::new(
|
||||||
backend_handler,
|
AccessControlledBackendHandler::new(backend_handler),
|
||||||
ldap_base_dn,
|
ldap_base_dn,
|
||||||
ignored_user_attributes,
|
ignored_user_attributes,
|
||||||
ignored_group_attributes,
|
ignored_group_attributes,
|
||||||
|
@ -145,7 +148,7 @@ pub fn build_ldap_server<Backend>(
|
||||||
server_builder: ServerBuilder,
|
server_builder: ServerBuilder,
|
||||||
) -> Result<ServerBuilder>
|
) -> Result<ServerBuilder>
|
||||||
where
|
where
|
||||||
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
|
Backend: BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
|
||||||
{
|
{
|
||||||
let context = (
|
let context = (
|
||||||
backend_handler,
|
backend_handler,
|
||||||
|
|
|
@ -21,17 +21,29 @@ async fn send_email(to: Mailbox, subject: &str, body: String, options: &MailOpti
|
||||||
.reply_to(reply_to)
|
.reply_to(reply_to)
|
||||||
.to(to)
|
.to(to)
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.body(body)?;
|
.singlepart(
|
||||||
|
lettre::message::SinglePart::builder()
|
||||||
|
.header(lettre::message::header::ContentType::TEXT_PLAIN)
|
||||||
|
.body(body),
|
||||||
|
)?;
|
||||||
|
let mut mailer = match options.smtp_encryption {
|
||||||
|
SmtpEncryption::None => {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&options.server)
|
||||||
|
}
|
||||||
|
SmtpEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&options.server)?,
|
||||||
|
SmtpEncryption::StartTls => {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&options.server)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if options.user.as_str() != "" {
|
||||||
let creds = Credentials::new(
|
let creds = Credentials::new(
|
||||||
options.user.clone(),
|
options.user.clone(),
|
||||||
options.password.unsecure().to_string(),
|
options.password.unsecure().to_string(),
|
||||||
);
|
);
|
||||||
let relay_factory = match options.smtp_encryption {
|
mailer = mailer.credentials(creds)
|
||||||
SmtpEncryption::TLS => AsyncSmtpTransport::<Tokio1Executor>::relay,
|
}
|
||||||
SmtpEncryption::STARTTLS => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay,
|
|
||||||
};
|
mailer.port(options.port).build().send(email).await?;
|
||||||
let mailer = relay_factory(&options.server)?.credentials(creds).build();
|
|
||||||
mailer.send(email).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,7 +64,9 @@ compromised. You should reset your password and contact an administrator.
|
||||||
To reset your password please visit the following URL: {}/reset-password/step2/{}
|
To reset your password please visit the following URL: {}/reset-password/step2/{}
|
||||||
|
|
||||||
Please contact an administrator if you did not initiate the process.",
|
Please contact an administrator if you did not initiate the process.",
|
||||||
username, domain, token
|
username,
|
||||||
|
domain.trim_end_matches('/'),
|
||||||
|
token
|
||||||
);
|
);
|
||||||
send_email(to, "[LLDAP] Password reset requested", body, options).await
|
send_email(to, "[LLDAP] Password reset requested", body, options).await
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod access_control;
|
||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod configuration;
|
pub mod configuration;
|
||||||
|
|
|
@ -7,10 +7,9 @@ use crate::domain::{
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
sea_query::Cond, ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, IntoActiveModel,
|
sea_query::{Cond, Expr},
|
||||||
QueryFilter, QuerySelect,
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QuerySelect,
|
||||||
};
|
};
|
||||||
use sea_query::Expr;
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
|
@ -24,11 +23,6 @@ fn gen_random_string(len: usize) -> String {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(FromQueryResult)]
|
|
||||||
struct OnlyJwtHash {
|
|
||||||
jwt_hash: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TcpBackendHandler for SqlBackendHandler {
|
impl TcpBackendHandler for SqlBackendHandler {
|
||||||
#[instrument(skip_all, level = "debug")]
|
#[instrument(skip_all, level = "debug")]
|
||||||
|
@ -37,11 +31,11 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(JwtStorageColumn::JwtHash)
|
.column(JwtStorageColumn::JwtHash)
|
||||||
.filter(JwtStorageColumn::Blacklisted.eq(true))
|
.filter(JwtStorageColumn::Blacklisted.eq(true))
|
||||||
.into_model::<OnlyJwtHash>()
|
.into_tuple::<(i64,)>()
|
||||||
.all(&self.sql_pool)
|
.all(&self.sql_pool)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|m| m.jwt_hash as u64)
|
.map(|m| m.0 as u64)
|
||||||
.collect::<HashSet<u64>>())
|
.collect::<HashSet<u64>>())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,7 +55,7 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||||
let new_token = model::jwt_refresh_storage::Model {
|
let new_token = model::jwt_refresh_storage::Model {
|
||||||
refresh_token_hash: refresh_token_hash as i64,
|
refresh_token_hash: refresh_token_hash as i64,
|
||||||
user_id: user.clone(),
|
user_id: user.clone(),
|
||||||
expiry_date: chrono::Utc::now() + duration,
|
expiry_date: chrono::Utc::now().naive_utc() + duration,
|
||||||
}
|
}
|
||||||
.into_active_model();
|
.into_active_model();
|
||||||
new_token.insert(&self.sql_pool).await?;
|
new_token.insert(&self.sql_pool).await?;
|
||||||
|
@ -91,11 +85,11 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||||
.add(JwtStorageColumn::UserId.eq(user))
|
.add(JwtStorageColumn::UserId.eq(user))
|
||||||
.add(JwtStorageColumn::Blacklisted.eq(false)),
|
.add(JwtStorageColumn::Blacklisted.eq(false)),
|
||||||
)
|
)
|
||||||
.into_model::<OnlyJwtHash>()
|
.into_tuple::<(i64,)>()
|
||||||
.all(&self.sql_pool)
|
.all(&self.sql_pool)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|t| t.jwt_hash as u64)
|
.map(|t| t.0 as u64)
|
||||||
.collect::<HashSet<u64>>();
|
.collect::<HashSet<u64>>();
|
||||||
model::JwtStorage::update_many()
|
model::JwtStorage::update_many()
|
||||||
.col_expr(JwtStorageColumn::Blacklisted, Expr::value(true))
|
.col_expr(JwtStorageColumn::Blacklisted, Expr::value(true))
|
||||||
|
@ -131,7 +125,7 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||||
let new_token = model::password_reset_tokens::Model {
|
let new_token = model::password_reset_tokens::Model {
|
||||||
token: token.clone(),
|
token: token.clone(),
|
||||||
user_id: user.clone(),
|
user_id: user.clone(),
|
||||||
expiry_date: chrono::Utc::now() + duration,
|
expiry_date: chrono::Utc::now().naive_utc() + duration,
|
||||||
}
|
}
|
||||||
.into_active_model();
|
.into_active_model();
|
||||||
new_token.insert(&self.sql_pool).await?;
|
new_token.insert(&self.sql_pool).await?;
|
||||||
|
|
|
@ -4,7 +4,7 @@ use std::collections::HashSet;
|
||||||
use crate::domain::{error::Result, types::UserId};
|
use crate::domain::{error::Result, types::UserId};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait TcpBackendHandler {
|
pub trait TcpBackendHandler: Sync {
|
||||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
||||||
async fn create_refresh_token(&self, user: &UserId) -> Result<(String, chrono::Duration)>;
|
async fn create_refresh_token(&self, user: &UserId) -> Result<(String, chrono::Duration)>;
|
||||||
async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool>;
|
async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool>;
|
||||||
|
@ -20,38 +20,3 @@ pub trait TcpBackendHandler {
|
||||||
|
|
||||||
async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
|
async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::domain::{handler::*, types::*};
|
|
||||||
#[cfg(test)]
|
|
||||||
mockall::mock! {
|
|
||||||
pub TestTcpBackendHandler{}
|
|
||||||
impl Clone for TestTcpBackendHandler {
|
|
||||||
fn clone(&self) -> Self;
|
|
||||||
}
|
|
||||||
#[async_trait]
|
|
||||||
impl LoginHandler for TestTcpBackendHandler {
|
|
||||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
|
||||||
}
|
|
||||||
#[async_trait]
|
|
||||||
impl GroupBackendHandler for TestTcpBackendHandler {
|
|
||||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
|
||||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
|
||||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
|
||||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
|
||||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
|
||||||
}
|
|
||||||
#[async_trait]
|
|
||||||
impl UserBackendHandler for TestBackendHandler {
|
|
||||||
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
|
|
||||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
|
||||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
|
||||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
|
||||||
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
|
|
||||||
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
|
|
||||||
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
|
||||||
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
|
||||||
}
|
|
||||||
#[async_trait]
|
|
||||||
impl BackendHandler for TestTcpBackendHandler {}
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ use crate::{
|
||||||
opaque_handler::OpaqueHandler,
|
opaque_handler::OpaqueHandler,
|
||||||
},
|
},
|
||||||
infra::{
|
infra::{
|
||||||
|
access_control::{AccessControlledBackendHandler, ReadonlyBackendHandler},
|
||||||
auth_service,
|
auth_service,
|
||||||
configuration::{Configuration, MailOptions},
|
configuration::{Configuration, MailOptions},
|
||||||
logging::CustomRootSpanBuilder,
|
logging::CustomRootSpanBuilder,
|
||||||
|
@ -12,12 +13,12 @@ use crate::{
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use actix_files::{Files, NamedFile};
|
use actix_files::{Files, NamedFile};
|
||||||
use actix_http::HttpServiceBuilder;
|
use actix_http::{header, HttpServiceBuilder};
|
||||||
use actix_server::ServerBuilder;
|
use actix_server::ServerBuilder;
|
||||||
use actix_service::map_config;
|
use actix_service::map_config;
|
||||||
use actix_web::{dev::AppConfig, web, App, HttpResponse};
|
use actix_web::{dev::AppConfig, guard, middleware, web, App, HttpResponse, Responder};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use hmac::{Hmac, NewMac};
|
use hmac::Hmac;
|
||||||
use sha2::Sha512;
|
use sha2::Sha512;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
@ -37,6 +38,8 @@ pub enum TcpError {
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
#[error("Internal server error: `{0}`")]
|
#[error("Internal server error: `{0}`")]
|
||||||
InternalServerError(String),
|
InternalServerError(String),
|
||||||
|
#[error("Not found: `{0}`")]
|
||||||
|
NotFoundError(String),
|
||||||
#[error("Unauthorized: `{0}`")]
|
#[error("Unauthorized: `{0}`")]
|
||||||
UnauthorizedError(String),
|
UnauthorizedError(String),
|
||||||
}
|
}
|
||||||
|
@ -57,12 +60,23 @@ pub(crate) fn error_to_http_response(error: TcpError) -> HttpResponse {
|
||||||
| DomainError::EntityNotFound(_) => HttpResponse::BadRequest(),
|
| DomainError::EntityNotFound(_) => HttpResponse::BadRequest(),
|
||||||
},
|
},
|
||||||
TcpError::BadRequest(_) => HttpResponse::BadRequest(),
|
TcpError::BadRequest(_) => HttpResponse::BadRequest(),
|
||||||
|
TcpError::NotFoundError(_) => HttpResponse::NotFound(),
|
||||||
TcpError::InternalServerError(_) => HttpResponse::InternalServerError(),
|
TcpError::InternalServerError(_) => HttpResponse::InternalServerError(),
|
||||||
TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(),
|
TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(),
|
||||||
}
|
}
|
||||||
.body(error.to_string())
|
.body(error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wasm_handler() -> actix_web::Result<impl Responder> {
|
||||||
|
Ok(
|
||||||
|
actix_files::NamedFile::open_async("./app/pkg/lldap_app_bg.wasm.gz")
|
||||||
|
.await?
|
||||||
|
.customize()
|
||||||
|
.insert_header(header::ContentEncoding::Gzip)
|
||||||
|
.insert_header((header::CONTENT_TYPE, "application/wasm")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn http_config<Backend>(
|
fn http_config<Backend>(
|
||||||
cfg: &mut web::ServiceConfig,
|
cfg: &mut web::ServiceConfig,
|
||||||
backend_handler: Backend,
|
backend_handler: Backend,
|
||||||
|
@ -71,23 +85,37 @@ fn http_config<Backend>(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
mail_options: MailOptions,
|
mail_options: MailOptions,
|
||||||
) where
|
) where
|
||||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
|
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
|
||||||
{
|
{
|
||||||
|
let enable_password_reset = mail_options.enable_password_reset;
|
||||||
cfg.app_data(web::Data::new(AppState::<Backend> {
|
cfg.app_data(web::Data::new(AppState::<Backend> {
|
||||||
backend_handler,
|
backend_handler: AccessControlledBackendHandler::new(backend_handler),
|
||||||
jwt_key: Hmac::new_varkey(jwt_secret.unsecure().as_bytes()).unwrap(),
|
jwt_key: hmac::Mac::new_from_slice(jwt_secret.unsecure().as_bytes()).unwrap(),
|
||||||
jwt_blacklist: RwLock::new(jwt_blacklist),
|
jwt_blacklist: RwLock::new(jwt_blacklist),
|
||||||
server_url,
|
server_url,
|
||||||
mail_options,
|
mail_options,
|
||||||
}))
|
}))
|
||||||
.route("/health", web::get().to(|| HttpResponse::Ok().finish()))
|
.route(
|
||||||
.service(web::scope("/auth").configure(auth_service::configure_server::<Backend>))
|
"/health",
|
||||||
|
web::get().to(|| async { HttpResponse::Ok().finish() }),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::scope("/auth")
|
||||||
|
.configure(|cfg| auth_service::configure_server::<Backend>(cfg, enable_password_reset)),
|
||||||
|
)
|
||||||
// API endpoint.
|
// API endpoint.
|
||||||
.service(
|
.service(
|
||||||
web::scope("/api")
|
web::scope("/api")
|
||||||
.wrap(auth_service::CookieToHeaderTranslatorFactory)
|
.wrap(auth_service::CookieToHeaderTranslatorFactory)
|
||||||
.configure(super::graphql::api::configure_endpoint::<Backend>),
|
.configure(super::graphql::api::configure_endpoint::<Backend>),
|
||||||
)
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/pkg/lldap_app_bg.wasm").route(
|
||||||
|
web::route()
|
||||||
|
.wrap(middleware::Compress::default())
|
||||||
|
.to(wasm_handler),
|
||||||
|
),
|
||||||
|
)
|
||||||
// Serve the /pkg path with the compiled WASM app.
|
// Serve the /pkg path with the compiled WASM app.
|
||||||
.service(Files::new("/pkg", "./app/pkg"))
|
.service(Files::new("/pkg", "./app/pkg"))
|
||||||
// Serve static files
|
// Serve static files
|
||||||
|
@ -95,28 +123,45 @@ fn http_config<Backend>(
|
||||||
// Serve static fonts
|
// Serve static fonts
|
||||||
.service(Files::new("/static/fonts", "./app/static/fonts"))
|
.service(Files::new("/static/fonts", "./app/static/fonts"))
|
||||||
// Default to serve index.html for unknown routes, to support routing.
|
// Default to serve index.html for unknown routes, to support routing.
|
||||||
.service(
|
.default_service(web::route().guard(guard::Get()).to(index));
|
||||||
web::scope("/")
|
|
||||||
.route("", web::get().to(index)) // this is necessary because the below doesn't match a request for "/"
|
|
||||||
.route(".*", web::get().to(index)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct AppState<Backend> {
|
pub(crate) struct AppState<Backend> {
|
||||||
pub backend_handler: Backend,
|
pub backend_handler: AccessControlledBackendHandler<Backend>,
|
||||||
pub jwt_key: Hmac<Sha512>,
|
pub jwt_key: Hmac<Sha512>,
|
||||||
pub jwt_blacklist: RwLock<HashSet<u64>>,
|
pub jwt_blacklist: RwLock<HashSet<u64>>,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub mail_options: MailOptions,
|
pub mail_options: MailOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<Backend: BackendHandler> AppState<Backend> {
|
||||||
|
pub fn get_readonly_handler(&self) -> &impl ReadonlyBackendHandler {
|
||||||
|
self.backend_handler.unsafe_get_handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<Backend: TcpBackendHandler> AppState<Backend> {
|
||||||
|
pub fn get_tcp_handler(&self) -> &impl TcpBackendHandler {
|
||||||
|
self.backend_handler.unsafe_get_handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<Backend: OpaqueHandler> AppState<Backend> {
|
||||||
|
pub fn get_opaque_handler(&self) -> &impl OpaqueHandler {
|
||||||
|
self.backend_handler.unsafe_get_handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<Backend: LoginHandler> AppState<Backend> {
|
||||||
|
pub fn get_login_handler(&self) -> &impl LoginHandler {
|
||||||
|
self.backend_handler.unsafe_get_handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn build_tcp_server<Backend>(
|
pub async fn build_tcp_server<Backend>(
|
||||||
config: &Configuration,
|
config: &Configuration,
|
||||||
backend_handler: Backend,
|
backend_handler: Backend,
|
||||||
server_builder: ServerBuilder,
|
server_builder: ServerBuilder,
|
||||||
) -> Result<ServerBuilder>
|
) -> Result<ServerBuilder>
|
||||||
where
|
where
|
||||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
|
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
|
||||||
{
|
{
|
||||||
let jwt_secret = config.jwt_secret.clone();
|
let jwt_secret = config.jwt_secret.clone();
|
||||||
let jwt_blacklist = backend_handler
|
let jwt_blacklist = backend_handler
|
||||||
|
@ -125,6 +170,7 @@ where
|
||||||
.context("while getting the jwt blacklist")?;
|
.context("while getting the jwt blacklist")?;
|
||||||
let server_url = config.http_url.clone();
|
let server_url = config.http_url.clone();
|
||||||
let mail_options = config.smtp_options.clone();
|
let mail_options = config.smtp_options.clone();
|
||||||
|
let verbose = config.verbose;
|
||||||
info!("Starting the API/web server on port {}", config.http_port);
|
info!("Starting the API/web server on port {}", config.http_port);
|
||||||
server_builder
|
server_builder
|
||||||
.bind(
|
.bind(
|
||||||
|
@ -136,10 +182,13 @@ where
|
||||||
let jwt_blacklist = jwt_blacklist.clone();
|
let jwt_blacklist = jwt_blacklist.clone();
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
let mail_options = mail_options.clone();
|
let mail_options = mail_options.clone();
|
||||||
HttpServiceBuilder::new()
|
HttpServiceBuilder::default()
|
||||||
.finish(map_config(
|
.finish(map_config(
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(tracing_actix_web::TracingLogger::<CustomRootSpanBuilder>::new())
|
.wrap(actix_web::middleware::Condition::new(
|
||||||
|
verbose,
|
||||||
|
tracing_actix_web::TracingLogger::<CustomRootSpanBuilder>::new(),
|
||||||
|
))
|
||||||
.configure(move |cfg| {
|
.configure(move |cfg| {
|
||||||
http_config(
|
http_config(
|
||||||
cfg,
|
cfg,
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![forbid(non_ascii_idents)]
|
#![forbid(non_ascii_idents)]
|
||||||
#![allow(clippy::nonstandard_macro_braces)]
|
// TODO: Remove next line once ubuntu upgrades rustc to >=1.67.1
|
||||||
|
#![allow(clippy::uninlined_format_args)]
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::{
|
domain::{
|
||||||
handler::{CreateUserRequest, GroupBackendHandler, GroupRequestFilter, UserBackendHandler},
|
handler::{
|
||||||
|
CreateUserRequest, GroupBackendHandler, GroupListerBackendHandler, GroupRequestFilter,
|
||||||
|
UserBackendHandler,
|
||||||
|
},
|
||||||
sql_backend_handler::SqlBackendHandler,
|
sql_backend_handler::SqlBackendHandler,
|
||||||
sql_opaque_handler::register_password,
|
sql_opaque_handler::register_password,
|
||||||
},
|
},
|
||||||
|
@ -158,6 +162,8 @@ fn run_healthcheck(opts: RunOpts) -> Result<()> {
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
|
info!("Starting healthchecks");
|
||||||
|
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
let delay = Duration::from_millis(3000);
|
let delay = Duration::from_millis(3000);
|
||||||
let (ldap, ldaps, api) = runtime.block_on(async {
|
let (ldap, ldaps, api) = runtime.block_on(async {
|
||||||
|
@ -168,17 +174,53 @@ fn run_healthcheck(opts: RunOpts) -> Result<()> {
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut failure = false;
|
let failure = [ldap, ldaps, api]
|
||||||
[ldap, ldaps, api]
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(Result::err)
|
.flat_map(|res| {
|
||||||
.for_each(|e| {
|
if let Err(e) = &res {
|
||||||
failure = true;
|
error!("Error running the health check: {:#}", e);
|
||||||
error!("{:#}", e)
|
}
|
||||||
});
|
res
|
||||||
|
})
|
||||||
|
.any(|r| r.is_err());
|
||||||
|
if failure {
|
||||||
|
error!("Healthcheck failed");
|
||||||
|
}
|
||||||
std::process::exit(i32::from(failure))
|
std::process::exit(i32::from(failure))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_schema(database_url: String) -> Result<()> {
|
||||||
|
let sql_pool = {
|
||||||
|
let mut sql_opt = sea_orm::ConnectOptions::new(database_url.clone());
|
||||||
|
sql_opt
|
||||||
|
.max_connections(1)
|
||||||
|
.sqlx_logging(true)
|
||||||
|
.sqlx_logging_level(log::LevelFilter::Debug);
|
||||||
|
Database::connect(sql_opt).await?
|
||||||
|
};
|
||||||
|
domain::sql_tables::init_table(&sql_pool)
|
||||||
|
.await
|
||||||
|
.context("while creating base tables")?;
|
||||||
|
infra::jwt_sql_tables::init_table(&sql_pool)
|
||||||
|
.await
|
||||||
|
.context("while creating jwt tables")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_schema_command(opts: RunOpts) -> Result<()> {
|
||||||
|
debug!("CLI: {:#?}", &opts);
|
||||||
|
let config = infra::configuration::init(opts)?;
|
||||||
|
infra::logging::init(&config)?;
|
||||||
|
let database_url = config.database_url;
|
||||||
|
|
||||||
|
actix::run(
|
||||||
|
create_schema(database_url).unwrap_or_else(|e| error!("Could not create schema: {:#}", e)),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
info!("Schema created successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli_opts = infra::cli::init();
|
let cli_opts = infra::cli::init();
|
||||||
match cli_opts.command {
|
match cli_opts.command {
|
||||||
|
@ -186,5 +228,6 @@ fn main() -> Result<()> {
|
||||||
Command::Run(opts) => run_server_command(opts),
|
Command::Run(opts) => run_server_command(opts),
|
||||||
Command::HealthCheck(opts) => run_healthcheck(opts),
|
Command::HealthCheck(opts) => run_healthcheck(opts),
|
||||||
Command::SendTestEmail(opts) => send_test_email_command(opts),
|
Command::SendTestEmail(opts) => send_test_email_command(opts),
|
||||||
|
Command::CreateSchema(opts) => create_schema_command(opts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
25
set-password/Cargo.toml
Normal file
25
set-password/Cargo.toml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
[package]
|
||||||
|
name = "lldap_set_password"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "*"
|
||||||
|
rand = "0.8"
|
||||||
|
serde = "1"
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[dependencies.clap]
|
||||||
|
features = ["std", "color", "suggestions", "derive", "env"]
|
||||||
|
version = "4"
|
||||||
|
|
||||||
|
[dependencies.lldap_auth]
|
||||||
|
path = "../auth"
|
||||||
|
features = ["opaque_client"]
|
||||||
|
|
||||||
|
[dependencies.reqwest]
|
||||||
|
version = "*"
|
||||||
|
default-features = false
|
||||||
|
features = ["json", "blocking", "rustls-tls"]
|
133
set-password/src/main.rs
Normal file
133
set-password/src/main.rs
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
use anyhow::{bail, ensure, Context, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
use lldap_auth::{opaque, registration};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Set the password for a user in LLDAP.
|
||||||
|
#[derive(Debug, Parser, Clone)]
|
||||||
|
pub struct CliOpts {
|
||||||
|
/// Base LLDAP url, e.g. "https://lldap/".
|
||||||
|
#[clap(short, long)]
|
||||||
|
pub base_url: String,
|
||||||
|
|
||||||
|
/// Admin username.
|
||||||
|
#[clap(long, default_value = "admin")]
|
||||||
|
pub admin_username: String,
|
||||||
|
|
||||||
|
/// Admin password.
|
||||||
|
#[clap(long)]
|
||||||
|
pub admin_password: Option<String>,
|
||||||
|
|
||||||
|
/// Connection token (JWT).
|
||||||
|
#[clap(short, long)]
|
||||||
|
pub token: Option<String>,
|
||||||
|
|
||||||
|
/// Username.
|
||||||
|
#[clap(short, long)]
|
||||||
|
pub username: String,
|
||||||
|
|
||||||
|
/// New password for the user.
|
||||||
|
#[clap(short, long)]
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_token(base_url: &str, username: &str, password: &str) -> Result<String> {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(format!("{base_url}/auth/simple/login"))
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(
|
||||||
|
serde_json::to_string(&lldap_auth::login::ClientSimpleLoginRequest {
|
||||||
|
username: username.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
})
|
||||||
|
.expect("Failed to encode the username/password as json to log in"),
|
||||||
|
)
|
||||||
|
.send()?
|
||||||
|
.error_for_status()?;
|
||||||
|
Ok(serde_json::from_str::<lldap_auth::login::ServerLoginResponse>(&response.text()?)?.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_server(url: &str, token: &str, body: impl Serialize) -> Result<String> {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let request = client
|
||||||
|
.post(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.bearer_auth(token)
|
||||||
|
.body(serde_json::to_string(&body)?);
|
||||||
|
let response = request.send()?.error_for_status()?;
|
||||||
|
Ok(response.text()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_start(
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
request: registration::ClientRegistrationStartRequest,
|
||||||
|
) -> Result<registration::ServerRegistrationStartResponse> {
|
||||||
|
let request = Some(request);
|
||||||
|
let data = call_server(
|
||||||
|
&format!("{base_url}/auth/opaque/register/start"),
|
||||||
|
token,
|
||||||
|
request,
|
||||||
|
)?;
|
||||||
|
serde_json::from_str(&data).context("Could not parse response")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_finish(
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
request: registration::ClientRegistrationFinishRequest,
|
||||||
|
) -> Result<()> {
|
||||||
|
let request = Some(request);
|
||||||
|
call_server(
|
||||||
|
&format!("{base_url}/auth/opaque/register/finish"),
|
||||||
|
token,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let opts = CliOpts::parse();
|
||||||
|
ensure!(
|
||||||
|
opts.password.len() >= 8,
|
||||||
|
"New password is too short, expected at least 8 characters"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
opts.base_url.starts_with("http://") || opts.base_url.starts_with("https://"),
|
||||||
|
"Base URL should start with `http://` or `https://`"
|
||||||
|
);
|
||||||
|
let token = match (opts.token.as_ref(), opts.admin_password.as_ref()) {
|
||||||
|
(Some(token), _) => token.clone(),
|
||||||
|
(None, Some(password)) => {
|
||||||
|
get_token(&opts.base_url, &opts.admin_username, password).context("While logging in")?
|
||||||
|
}
|
||||||
|
(None, None) => bail!("Either the token or the admin password is required"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rng = rand::rngs::OsRng;
|
||||||
|
let registration_start_request =
|
||||||
|
opaque::client::registration::start_registration(&opts.password, &mut rng)
|
||||||
|
.context("Could not initiate password change")?;
|
||||||
|
let start_request = registration::ClientRegistrationStartRequest {
|
||||||
|
username: opts.username.to_string(),
|
||||||
|
registration_start_request: registration_start_request.message,
|
||||||
|
};
|
||||||
|
let res = register_start(&opts.base_url, &token, start_request)?;
|
||||||
|
|
||||||
|
let registration_finish = opaque::client::registration::finish_registration(
|
||||||
|
registration_start_request.state,
|
||||||
|
res.registration_response,
|
||||||
|
&mut rng,
|
||||||
|
)
|
||||||
|
.context("Error during password change")?;
|
||||||
|
let req = registration::ClientRegistrationFinishRequest {
|
||||||
|
server_data: res.server_data,
|
||||||
|
registration_upload: registration_finish.message,
|
||||||
|
};
|
||||||
|
|
||||||
|
register_finish(&opts.base_url, &token, req)?;
|
||||||
|
|
||||||
|
println!("Successfully changed {}'s password", &opts.username);
|
||||||
|
Ok(())
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user