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
.devcontainer
.github/workflows
Cargo.lockCargo.tomlDockerfileREADME.mdapp
Cargo.tomlbuild.shindex.htmlindex_local.html
src
components
add_group_member.rsadd_user_to_group.rsapp.rschange_password.rscreate_group.rscreate_user.rsdelete_group.rsdelete_user.rsgroup_details.rsgroup_table.rslogin.rslogout.rsremove_user_from_group.rsreset_password_step1.rsreset_password_step2.rsrouter.rsselect.rsuser_details.rsuser_details_form.rsuser_table.rs
infra
lib.rsstatic
docs
example_configs
lldap_config.docker_template.tomlmigration-tool/src
server
Cargo.toml
src
domain
handler.rs
ldap
model
opaque_handler.rssql_backend_handler.rssql_group_backend_handler.rssql_migrations.rssql_opaque_handler.rssql_tables.rssql_user_backend_handler.rstypes.rsinfra
access_control.rsauth_service.rscli.rsconfiguration.rs
main.rsgraphql
healthcheck.rsjwt_sql_tables.rsldap_handler.rsldap_server.rsmail.rsmod.rssql_backend_handler.rstcp_backend_handler.rstcp_server.rsset-password
@ -13,12 +13,10 @@ RUN groupadd --gid $USER_GID $USERNAME \
|
||||
&& chmod 0440 /etc/sudoers.d/$USERNAME
|
||||
|
||||
RUN apt update && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||
apt update && \
|
||||
apt install -y --no-install-recommends nodejs libssl-dev musl-dev make perl curl
|
||||
apt install -y --no-install-recommends libssl-dev musl-dev make perl curl gzip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
|
||||
&& npm install -g rollup \
|
||||
&& rustup target add wasm32-unknown-unknown
|
||||
|
||||
USER $USERNAME
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"name": "LLDAP dev",
|
||||
"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 if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/amd64-bin/lldap target/lldap && \
|
||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
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/migration-tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
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/migration-tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armhf-bin/lldap target/lldap && \
|
||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
||||
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap target/lldap && \
|
||||
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/migration-tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
@ -42,6 +48,7 @@ COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/migration-tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
@ -98,10 +105,10 @@ RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
||||
"$USER" && \
|
||||
mkdir -p /data && \
|
||||
chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "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 if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/amd64-bin/lldap target/lldap && \
|
||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
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/migration-tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
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/migration-tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armhf-bin/lldap target/lldap && \
|
||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
||||
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap target/lldap && \
|
||||
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/migration-tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
@ -42,6 +48,7 @@ COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/migration-tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
@ -69,4 +76,4 @@ VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
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
|
||||
ENV PATH="/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||
|
||||
### Install build deps x86_64
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools && \
|
||||
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools gzip && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||
apt update && \
|
||||
apt install -y --no-install-recommends nodejs && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
npm install -g npm && \
|
||||
npm install -g yarn && \
|
||||
npm install -g pnpm
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
### Install build deps aarch64 build
|
||||
RUN dpkg --add-architecture arm64 && \
|
||||
apt update && \
|
||||
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross && \
|
||||
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross gzip && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
### armhf deps
|
||||
RUN dpkg --add-architecture armhf && \
|
||||
apt update && \
|
||||
apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross gzip && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rustup target add armv7-unknown-linux-gnueabihf
|
||||
|
||||
### Add musl-gcc aarch64 and x86_64
|
||||
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
||||
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 ./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"]
|
||||
|
501
.github/workflows/docker-build-static.yml
vendored
501
.github/workflows/docker-build-static.yml
vendored
@ -19,55 +19,54 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
# In total 5 jobs, all the jobs are containerized
|
||||
# ---
|
||||
|
||||
### CI Docs
|
||||
|
||||
# build-ui , create/compile the web
|
||||
## Use rustlang/rust:nighlty image
|
||||
### Install nodejs from nodesource repo
|
||||
### install wasm
|
||||
### install rollup
|
||||
### run app/build.sh
|
||||
### upload artifacts
|
||||
|
||||
# builds-armhf, build-aarch64, build-amd64 create binary for respective arch
|
||||
## Use rustlang/rust:nightly image
|
||||
### Add non-native architecture dpkg --add-architecture XXX
|
||||
### Install dev tool gcc g++, etc. per respective arch
|
||||
# build-bin
|
||||
## build-armhf, build-aarch64, build-amd64 , create binary for 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
|
||||
### Upload artifacts
|
||||
|
||||
## the CARGO_ env
|
||||
#CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
# This will determine which architecture lib will be used.
|
||||
### aarch64 and amd64 is musl based
|
||||
### armv7 is glibc based, musl had issue with time_t when cross compile https://github.com/rust-lang/libc/issues/1848
|
||||
|
||||
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
||||
|
||||
# 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.
|
||||
### Look into .github/workflows/Dockerfile.ci.debian or .github/workflowds/Dockerfile.ci.alpine
|
||||
|
||||
# On current https://hub.docker.com/_/rust
|
||||
# 1-bullseye, 1.61-bullseye, 1.61.0-bullseye, bullseye, 1, 1.61, 1.61.0, latest
|
||||
# create release artifacts
|
||||
### Fetch artifacts
|
||||
### Clean up web artifact
|
||||
### Setup folder structure
|
||||
### Compress
|
||||
### Upload
|
||||
|
||||
# cache
|
||||
## cargo
|
||||
## target
|
||||
# cache based on Cargo.lock per cargo target
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.65
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||
image: nitnelave/rust-dev:latest
|
||||
steps:
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev ca-certificates
|
||||
- name: setup node repo LTS
|
||||
run: curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
||||
- name: install nodejs
|
||||
run: apt install -y nodejs && npm -g install npm
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.4.0
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
@ -79,142 +78,42 @@ jobs:
|
||||
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
lldap-ui-
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.2.0
|
||||
- name: install rollup nodejs
|
||||
- name: Install rollup (nodejs)
|
||||
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
|
||||
env:
|
||||
RUSTFLAGS: ""
|
||||
- name: build frontend
|
||||
- name: Build frontend
|
||||
run: ./app/build.sh
|
||||
- name: check path
|
||||
- name: Check build path
|
||||
run: ls -al app/
|
||||
- name: upload ui artifacts
|
||||
- name: Upload ui artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: app/
|
||||
|
||||
build-armhf:
|
||||
|
||||
build-bin:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
target: [armv7-unknown-linux-gnueabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
||||
container:
|
||||
image: rust:1.65
|
||||
image: nitnelave/rust-dev:latest
|
||||
env:
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-gnueabihf-ld
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
steps:
|
||||
- name: add armhf architecture
|
||||
run: dpkg --add-architecture armhf
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross tar ca-certificates
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: add armhf target
|
||||
run: rustup target add armv7-unknown-linux-gnueabihf
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.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_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_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.2.0
|
||||
uses: actions/checkout@v3.4.0
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
@ -223,82 +122,111 @@ jobs:
|
||||
.cargo/registry/cache
|
||||
.cargo/git/db
|
||||
target
|
||||
key: lldap-bin-amd64-${{ hashFiles('**/Cargo.lock') }}
|
||||
key: lldap-bin-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
lldap-bin-amd64-
|
||||
- name: install musl
|
||||
run: apt update && apt install -y musl-tools tar wget
|
||||
# - name: fetch musl-gcc
|
||||
# run: |
|
||||
# wget -c https://musl.cc/x86_64-linux-musl-cross.tgz
|
||||
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
|
||||
# echo "/opt/x86_64-linux-musl-cross:/opt/x86_64-linux-musl-cross/bin" >> $GITHUB_PATH
|
||||
- name: add x86_64 target
|
||||
run: rustup target add x86_64-unknown-linux-musl
|
||||
- name: build x86_64 lldap
|
||||
run: cargo build --target=x86_64-unknown-linux-musl --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/x86_64-unknown-linux-musl/release/
|
||||
- name: upload amd64 lldap artifacts
|
||||
lldap-bin-${{ matrix.target }}-
|
||||
- name: Compile ${{ matrix.target }} lldap and tools
|
||||
run: cargo build --target=${{ matrix.target }} --release -p lldap -p migration-tool -p lldap_set_password
|
||||
- name: Check path
|
||||
run: ls -al target/release
|
||||
- name: Upload ${{ matrix.target}} lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: amd64-lldap-bin
|
||||
path: target/x86_64-unknown-linux-musl/release/lldap
|
||||
- name: upload amd64 migration-tool artifacts
|
||||
name: ${{ matrix.target}}-lldap-bin
|
||||
path: target/${{ matrix.target }}/release/lldap
|
||||
- name: Upload ${{ matrix.target }} migration tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: amd64-migration-tool-bin
|
||||
path: target/x86_64-unknown-linux-musl/release/migration-tool
|
||||
name: ${{ matrix.target }}-migration-tool-bin
|
||||
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:
|
||||
needs: [build-ui,build-armhf,build-aarch64,build-amd64]
|
||||
needs: [build-ui, build-bin]
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: install rsync
|
||||
run: sudo apt update && sudo apt install -y rsync
|
||||
- name: fetch repo
|
||||
uses: actions/checkout@v3.2.0
|
||||
|
||||
- name: Download armhf lldap artifacts
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.4.0
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-lldap-bin
|
||||
path: bin/armhf-bin
|
||||
- name: Download armhf migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-migration-tool-bin
|
||||
path: bin/armhf-bin
|
||||
|
||||
- name: Download aarch64 lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: aarch64-lldap-bin
|
||||
path: bin/aarch64-bin
|
||||
- name: Download aarch64 migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: aarch64-migration-tool-bin
|
||||
path: bin/aarch64-bin
|
||||
|
||||
- name: Download amd64 lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: amd64-lldap-bin
|
||||
path: bin/amd64-bin
|
||||
- name: Download amd64 migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: amd64-migration-tool-bin
|
||||
path: bin/amd64-bin
|
||||
|
||||
- name: check bin path
|
||||
run: ls -al bin/
|
||||
path: bin
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
@ -306,7 +234,7 @@ jobs:
|
||||
name: ui
|
||||
path: web
|
||||
|
||||
- name: setup qemu
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
|
||||
@ -325,13 +253,6 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: parse tag
|
||||
uses: gacts/github-slug@v1
|
||||
@ -344,39 +265,39 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
######################
|
||||
#### latest build ####
|
||||
######################
|
||||
########################################
|
||||
#### docker image :latest tag build ####
|
||||
########################################
|
||||
- name: Build and push latest alpine
|
||||
if: github.event_name != 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: ./.github/workflows/Dockerfile.ci.alpine
|
||||
tags: nitnelave/lldap:latest, nitnelave/lldap:latest-alpine
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push latest debian
|
||||
if: github.event_name != 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./.github/workflows/Dockerfile.ci.debian
|
||||
tags: nitnelave/lldap:latest-debian
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
#######################
|
||||
#### release build ####
|
||||
#######################
|
||||
########################################
|
||||
#### docker image :semver tag build ####
|
||||
########################################
|
||||
- name: Build and push release alpine
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@ -384,12 +305,12 @@ jobs:
|
||||
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
|
||||
file: ./.github/workflows/Dockerfile.ci.alpine
|
||||
tags: nitnelave/lldap:stable, nitnelave/lldap:stable-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine.${{ steps.slug.outputs.version-minor }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-alpine
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push release debian
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
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.
|
||||
file: ./.github/workflows/Dockerfile.ci.debian
|
||||
tags: nitnelave/lldap:stable-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-debian
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Move cache
|
||||
run: rsync -r /tmp/.buildx-cache-new /tmp/.buildx-cache --delete
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
@ -411,75 +329,86 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: nitnelave/lldap
|
||||
|
||||
|
||||
###############################################################
|
||||
### Download artifacts, clean up ui, upload to release page ###
|
||||
###############################################################
|
||||
create-release-artifacts:
|
||||
needs: [build-ui,build-armhf,build-aarch64,build-amd64]
|
||||
needs: [build-ui, build-bin]
|
||||
name: Create release artifacts
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Download armhf lldap artifacts
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-lldap-bin
|
||||
path: bin/armhf-bin
|
||||
- name: Download armhf migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-migration-tool-bin
|
||||
path: bin/armhf-bin
|
||||
- name: Fix binary name armhf
|
||||
run: mv bin/armhf-bin/lldap bin/armhf-bin/lldap-armhf && mv bin/armhf-bin/migration-tool bin/armhf-bin/migration-tool-armhf
|
||||
|
||||
- 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: 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
|
||||
path: bin/
|
||||
- name: Check file
|
||||
run: ls -alR bin/
|
||||
- name: Fixing Filename
|
||||
run: |
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap bin/aarch64-lldap
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap bin/amd64-lldap
|
||||
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap bin/armhf-lldap
|
||||
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
|
||||
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool bin/armhf-migration-tool
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/aarch64-lldap_set_password
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/amd64-lldap_set_password
|
||||
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
|
||||
chmod +x bin/*-lldap
|
||||
chmod +x bin/*-migration-tool
|
||||
chmod +x bin/*-lldap_set_password
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
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
|
||||
- name: compress web
|
||||
run: sudo apt update && sudo apt install -y zip && zip -r web.zip app/
|
||||
- name: Fetch web components
|
||||
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
|
||||
id: create_release
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "bin/armhf-bin/lldap-armhf,
|
||||
bin/aarch64-bin/lldap-aarch64,
|
||||
bin/amd64-bin/lldap-amd64,
|
||||
bin/armhf-bin/migration-tool-armhf,
|
||||
bin/aarch64-bin/migration-tool-aarch64,
|
||||
bin/amd64-bin/migration-tool-amd64,
|
||||
web.zip"
|
||||
artifacts: aarch64-lldap.tar.gz,
|
||||
amd64-lldap.tar.gz,
|
||||
armhf-lldap.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
8
.github/workflows/rust.yml
vendored
8
.github/workflows/rust.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.2.0
|
||||
uses: actions/checkout@v3.4.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
@ -53,7 +53,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.2.0
|
||||
uses: actions/checkout@v3.4.0
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@ -70,7 +70,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.2.0
|
||||
uses: actions/checkout@v3.4.0
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@ -87,7 +87,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.2.0
|
||||
uses: actions/checkout@v3.4.0
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||
|
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",
|
||||
"auth",
|
||||
"app",
|
||||
"migration-tool"
|
||||
"migration-tool",
|
||||
"set-password",
|
||||
]
|
||||
|
||||
default-members = ["server"]
|
||||
|
||||
# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged.
|
||||
[patch.crates-io.ldap3_proto]
|
||||
git = 'https://github.com/nitnelave/ldap3_server/'
|
||||
rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc'
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
[profile.release.package.lldap_app]
|
||||
opt-level = 's'
|
||||
|
||||
[patch.crates-io.opaque-ke]
|
||||
git = 'https://github.com/nitnelave/opaque-ke/'
|
||||
branch = 'zeroize_1.5'
|
||||
|
||||
[patch.crates-io.lber]
|
||||
git = 'https://github.com/inejge/ldap3/'
|
||||
|
16
Dockerfile
16
Dockerfile
@ -1,5 +1,5 @@
|
||||
# Build image
|
||||
FROM rust:alpine3.14 AS chef
|
||||
FROM rust:alpine3.16 AS chef
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
@ -11,7 +11,7 @@ RUN set -x \
|
||||
--uid 10001 \
|
||||
app \
|
||||
# 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
|
||||
WORKDIR /app
|
||||
@ -19,7 +19,6 @@ WORKDIR /app
|
||||
RUN set -x \
|
||||
# Install build tools
|
||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||
&& npm install rollup \
|
||||
&& rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Prepare the dependency list.
|
||||
@ -32,16 +31,17 @@ FROM chef AS builder
|
||||
COPY --from=planner /tmp/recipe.json recipe.json
|
||||
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||
&& cargo chef cook --release -p lldap \
|
||||
&& cargo chef cook --release -p migration-tool
|
||||
&& 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 --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.
|
||||
&& ./app/build.sh
|
||||
|
||||
# Final image
|
||||
FROM alpine:3.14
|
||||
FROM alpine:3.16
|
||||
|
||||
ENV GOSU_VERSION 1.14
|
||||
# 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/static app/static
|
||||
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 ./
|
||||
|
||||
RUN set -x \
|
||||
@ -94,4 +94,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
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"]
|
||||
|
199
README.md
199
README.md
@ -28,20 +28,20 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
- [About](#About)
|
||||
- [Installation](#Installation)
|
||||
- [With Docker](#With-Docker)
|
||||
- [From source](#From-source)
|
||||
- [Cross-compilation](#Cross-compilation)
|
||||
- [Client configuration](#Client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Sample client configurations](#Sample-client-configurations)
|
||||
- [Comparisons with other services](#Comparisons-with-other-services)
|
||||
- [vs OpenLDAP](#vs-openldap)
|
||||
- [vs FreeIPA](#vs-freeipa)
|
||||
- [I can't log in!](#i-cant-log-in)
|
||||
- [Contributions](#Contributions)
|
||||
- [About](#about)
|
||||
- [Installation](#installation)
|
||||
- [With Docker](#with-docker)
|
||||
- [From source](#from-source)
|
||||
- [Cross-compilation](#cross-compilation)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Sample client configurations](#sample-client-configurations)
|
||||
- [Comparisons with other services](#comparisons-with-other-services)
|
||||
- [vs OpenLDAP](#vs-openldap)
|
||||
- [vs FreeIPA](#vs-freeipa)
|
||||
- [I can't log in!](#i-cant-log-in)
|
||||
- [Contributions](#contributions)
|
||||
|
||||
## 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,
|
||||
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),
|
||||
* low resources,
|
||||
* opinionated with basic defaults so you don't have to understand the
|
||||
|
||||
- simple to setup (no messing around with `slapd`),
|
||||
- simple to manage (friendly web UI),
|
||||
- low resources,
|
||||
- opinionated with basic defaults so you don't have to understand the
|
||||
subtleties of LDAP.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
version: '3'
|
||||
version: "3"
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
@ -135,13 +136,16 @@ services:
|
||||
Then the service will listen on two ports, one for LDAP and one for the web
|
||||
front-end.
|
||||
|
||||
### With Kubernetes
|
||||
|
||||
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
||||
|
||||
### From source
|
||||
|
||||
To compile the project, you'll need:
|
||||
|
||||
* nodejs 16: [nodesource nodejs installation guide](https://github.com/nodesource/distributions)
|
||||
* curl: `sudo apt install curl`
|
||||
* Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
||||
- curl and gzip: `sudo apt install curl gzip`
|
||||
- Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
||||
|
||||
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
|
||||
cargo, you'll need:
|
||||
|
||||
* WASM-pack: `cargo install wasm-pack`
|
||||
* rollup.js: `npm install rollup`
|
||||
- WASM-pack: `cargo install wasm-pack`
|
||||
|
||||
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).
|
||||
@ -204,14 +207,15 @@ the config).
|
||||
### General configuration guide
|
||||
|
||||
To configure the services that will talk to LLDAP, here are the values:
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
- The LDAP password is from the configuration (same as to log in to the web
|
||||
UI).
|
||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
- The LDAP password is from the configuration (same as to log in to the web
|
||||
UI).
|
||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||
|
||||
Testing group membership through `memberOf` is supported, so you can have a
|
||||
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||
@ -226,57 +230,64 @@ administration access to many services.
|
||||
Some specific clients have been tested to work and come with sample
|
||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||
folder for help with:
|
||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||
- [Dolibarr](example_configs/dolibarr.md)
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Nextcloud](example_configs/nextcloud.md)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [Vaultwarden](example_configs/vaultwarden.md)
|
||||
- [WeKan](example_configs/wekan.md)
|
||||
- [WG Portal](example_configs/wg_portal.env.example)
|
||||
- [XBackBone](example_configs/xbackbone_config.php)
|
||||
- [Zendto](example_configs/zendto.md)
|
||||
|
||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Authentik](example_configs/authentik.md)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||
- [Dex](example_configs/dex_config.yml)
|
||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||
- [Dolibarr](example_configs/dolibarr.md)
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Nextcloud](example_configs/nextcloud.md)
|
||||
- [Nexus](example_configs/nexus.md)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [Rancher](example_configs/rancher.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [Vaultwarden](example_configs/vaultwarden.md)
|
||||
- [WeKan](example_configs/wekan.md)
|
||||
- [WG Portal](example_configs/wg_portal.env.example)
|
||||
- [WikiJS](example_configs/wikijs.md)
|
||||
- [XBackBone](example_configs/xbackbone_config.php)
|
||||
- [Zendto](example_configs/zendto.md)
|
||||
|
||||
## Comparisons with other services
|
||||
|
||||
### vs OpenLDAP
|
||||
|
||||
OpenLDAP is a monster of a service that implements all of LDAP and all of its
|
||||
extensions, plus some of its own. That said, if you need all that flexibility,
|
||||
it might be what you need! Note that installation can be a bit painful
|
||||
(figuring out how to use `slapd`) and people have mixed experiences following
|
||||
tutorials online. If you don't configure it properly, you might end up storing
|
||||
passwords in clear, so a breach of your server would reveal all the stored
|
||||
passwords!
|
||||
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
|
||||
all of LDAP and all of its extensions, plus some of its own. That said, if you
|
||||
need all that flexibility, it might be what you need! Note that installation
|
||||
can be a bit painful (figuring out how to use `slapd`) and people have mixed
|
||||
experiences following tutorials online. If you don't configure it properly, you
|
||||
might end up storing passwords in clear, so a breach of your server would
|
||||
reveal all the stored passwords!
|
||||
|
||||
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
||||
install one (not that many that look nice) and configure it.
|
||||
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
|
||||
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
|
||||
However, it's not as flexible as OpenLDAP.
|
||||
|
||||
### vs FreeIPA
|
||||
|
||||
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
|
||||
Samba, you name it, it has it. In addition to user management, it also does
|
||||
security policies, single sign-on, certificate management, linux account
|
||||
management and so on.
|
||||
[FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
|
||||
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||
management, it also does security policies, single sign-on, certificate
|
||||
management, linux account management and so on.
|
||||
|
||||
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||
more complex to maintain, though.
|
||||
@ -285,25 +296,37 @@ LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
|
||||
configure (no messing around with DNS or security policies) and simpler to
|
||||
use. It also comes conveniently packed in a docker container.
|
||||
|
||||
### 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!
|
||||
|
||||
If you just set up the server, can get to the login page but the password you
|
||||
set isn't working, try the following:
|
||||
|
||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||
docker volume or mounted from the host filesystem.
|
||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||
or in the current directory). If there isn't, copy
|
||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||
(passwords, secrets, ...).
|
||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||
you specified the DB URL, which defaults to the current directory). If
|
||||
there isn't, check that the user running the command (user with ID 10001
|
||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||
docker volume or mounted from the host filesystem.
|
||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||
or in the current directory). If there isn't, copy
|
||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||
(passwords, secrets, ...).
|
||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||
you specified the DB URL, which defaults to the current directory). If
|
||||
there isn't, check that the user running the command (user with ID 10001
|
||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
|
||||
## Contributions
|
||||
|
||||
|
@ -8,22 +8,25 @@ include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
base64 = "0.13"
|
||||
gloo-console = "0.2.3"
|
||||
gloo-file = "0.2.3"
|
||||
gloo-net = "*"
|
||||
graphql_client = "0.10"
|
||||
http = "0.2"
|
||||
jwt = "0.13"
|
||||
rand = "0.8"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
url-escape = "0.1.1"
|
||||
validator = "=0.14"
|
||||
validator_derive = "*"
|
||||
wasm-bindgen = "0.2"
|
||||
yew = "0.18"
|
||||
yewtil = "*"
|
||||
yew-router = "0.15"
|
||||
wasm-bindgen-futures = "*"
|
||||
yew = "0.19.3"
|
||||
yew-router = "0.16"
|
||||
|
||||
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
||||
indexmap = "=1.6.2"
|
||||
url-escape = "0.1.1"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
@ -56,11 +59,11 @@ version = "0.24"
|
||||
|
||||
[dependencies.yew_form]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[dependencies.yew_form_derive]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[lib]
|
||||
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`'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wasm-pack build --target web
|
||||
|
||||
ROLLUP_BIN=$(which rollup 2>/dev/null)
|
||||
if [ -f ../node_modules/rollup/dist/bin/rollup ]
|
||||
if ! which gzip > /dev/null 2>&1
|
||||
then
|
||||
ROLLUP_BIN=../node_modules/rollup/dist/bin/rollup
|
||||
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`'
|
||||
>&2 echo '`gzip` not found.'
|
||||
exit 1
|
||||
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>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<script src="/static/main.js" type="module" defer></script>
|
||||
<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"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||
crossorigin="anonymous"
|
||||
as="style" />
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
||||
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
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
||||
@ -30,6 +34,11 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<script>
|
||||
function inDarkMode(){
|
||||
return darkmode.inDarkMode;
|
||||
}
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
|
@ -4,15 +4,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<script src="/static/main.js" type="module" defer></script>
|
||||
<link
|
||||
href="/static/bootstrap.min.css"
|
||||
href="/static/bootstrap-nightshade.min.css"
|
||||
rel="preload stylesheet"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||
as="style" />
|
||||
<script
|
||||
src="/static/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||
<script
|
||||
src="/static/darkmode.min.js"
|
||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/bootstrap-icons.css"
|
||||
@ -28,6 +31,11 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<script>
|
||||
function inDarkMode(){
|
||||
return darkmode.inDarkMode;
|
||||
}
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
|
@ -52,23 +52,25 @@ pub struct Props {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::UserListResponse(response) => {
|
||||
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) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
let user = self
|
||||
.selected_user
|
||||
.as_ref()
|
||||
.expect("Could not get selected user")
|
||||
.clone();
|
||||
// Remove the user from the dropdown.
|
||||
self.common.on_user_added_to_group.emit(user);
|
||||
ctx.props().on_user_added_to_group.emit(user);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_user.is_some();
|
||||
@ -88,23 +90,25 @@ impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||
}
|
||||
|
||||
impl AddGroupMemberComponent {
|
||||
fn get_user_list(&mut self) {
|
||||
fn get_user_list(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<ListUserNames, _>(
|
||||
ctx,
|
||||
list_user_names::Variables { filters: None },
|
||||
Msg::UserListResponse,
|
||||
"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() {
|
||||
None => return Ok(false),
|
||||
Some(user) => user.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
ctx,
|
||||
add_user_to_group::Variables {
|
||||
user: user_id,
|
||||
group: self.common.group_id,
|
||||
group: ctx.props().group_id,
|
||||
},
|
||||
Msg::AddMemberResponse,
|
||||
"Error trying to initiate adding the user to a group",
|
||||
@ -112,8 +116,8 @@ impl AddGroupMemberComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
|
||||
fn get_selectable_user_list(&self, ctx: &Context<Self>, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = ctx.props().users.iter().collect::<HashSet<_>>();
|
||||
user_list
|
||||
.iter()
|
||||
.filter(|u| !user_groups.contains(u))
|
||||
@ -126,41 +130,39 @@ impl Component for AddGroupMemberComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user_list: None,
|
||||
selected_user: None,
|
||||
};
|
||||
res.get_user_list();
|
||||
res.get_user_list(ctx);
|
||||
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(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
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)]
|
||||
let make_select_option = |user: User| {
|
||||
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! {
|
||||
<div class="row">
|
||||
<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
|
||||
.into_iter()
|
||||
@ -172,8 +174,8 @@ impl Component for AddGroupMemberComponent {
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
||||
disabled={self.selected_user.is_none() || self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitAddMember)}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Add to group"}
|
||||
</button>
|
||||
|
@ -64,16 +64,18 @@ pub struct Props {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::GroupListResponse(response) => {
|
||||
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) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
// Adding the user to the group succeeded, we're not in the process of adding a
|
||||
// group anymore.
|
||||
let group = self
|
||||
@ -82,7 +84,7 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
.expect("Could not get selected group")
|
||||
.clone();
|
||||
// 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) => {
|
||||
let was_some = self.selected_group.is_some();
|
||||
@ -102,22 +104,24 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
}
|
||||
|
||||
impl AddUserToGroupComponent {
|
||||
fn get_group_list(&mut self) {
|
||||
fn get_group_list(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetGroupList, _>(
|
||||
ctx,
|
||||
get_group_list::Variables,
|
||||
Msg::GroupListResponse,
|
||||
"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 {
|
||||
None => return Ok(false),
|
||||
Some(group) => group.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
ctx,
|
||||
add_user_to_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
user: ctx.props().username.clone(),
|
||||
group: group_id,
|
||||
},
|
||||
Msg::AddGroupResponse,
|
||||
@ -126,8 +130,8 @@ impl AddUserToGroupComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
|
||||
fn get_selectable_group_list(&self, props: &Props, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = props.groups.iter().collect::<HashSet<_>>();
|
||||
group_list
|
||||
.iter()
|
||||
.filter(|g| !user_groups.contains(g))
|
||||
@ -139,41 +143,39 @@ impl AddUserToGroupComponent {
|
||||
impl Component for AddUserToGroupComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group_list: None,
|
||||
selected_group: None,
|
||||
};
|
||||
res.get_group_list();
|
||||
res.get_group_list(ctx);
|
||||
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(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
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)]
|
||||
let make_select_option = |group: Group| {
|
||||
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! {
|
||||
<div class="row">
|
||||
<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
|
||||
.into_iter()
|
||||
@ -185,8 +187,8 @@ impl Component for AddUserToGroupComponent {
|
||||
<div class="col-sm-3">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
||||
disabled={self.selected_group.is_none() || self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitAddGroup)}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Add to group"}
|
||||
</button>
|
||||
|
@ -10,104 +10,143 @@ use crate::{
|
||||
logout::LogoutButton,
|
||||
reset_password_step1::ResetPasswordStep1Form,
|
||||
reset_password_step2::ResetPasswordStep2Form,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
router::{AppRoute, Link, Redirect},
|
||||
user_details::UserDetails,
|
||||
user_table::UserTable,
|
||||
},
|
||||
infra::cookies::get_cookie,
|
||||
};
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
router::Router,
|
||||
service::RouteService,
|
||||
infra::{api::HostService, cookies::get_cookie},
|
||||
};
|
||||
|
||||
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 {
|
||||
link: ComponentLink<Self>,
|
||||
user_info: Option<(String, bool)>,
|
||||
redirect_to: Option<AppRoute>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
password_reset_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Login((String, bool)),
|
||||
Logout,
|
||||
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||
}
|
||||
|
||||
impl Component for App {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut app = Self {
|
||||
link,
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let app = Self {
|
||||
user_info: get_cookie("user_id")
|
||||
.unwrap_or_else(|e| {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
None
|
||||
})
|
||||
.and_then(|u| {
|
||||
get_cookie("is_admin")
|
||||
.map(|so| so.map(|s| (u, s == "true")))
|
||||
.unwrap_or_else(|e| {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
None
|
||||
})
|
||||
}),
|
||||
redirect_to: Self::get_redirect_route(),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
redirect_to: Self::get_redirect_route(ctx),
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::Login((user_name, is_admin)) => {
|
||||
self.user_info = Some((user_name.clone(), is_admin));
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
self.redirect_to.take().unwrap_or_else(|| {
|
||||
if is_admin {
|
||||
AppRoute::ListUsers
|
||||
} else {
|
||||
AppRoute::UserDetails(user_name.clone())
|
||||
}
|
||||
}),
|
||||
)));
|
||||
history.push(self.redirect_to.take().unwrap_or_else(|| {
|
||||
if is_admin {
|
||||
AppRoute::ListUsers
|
||||
} else {
|
||||
AppRoute::UserDetails {
|
||||
user_id: user_name.clone(),
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
Msg::Logout => {
|
||||
self.user_info = 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
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let link = self.link.clone();
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link().clone();
|
||||
let is_admin = self.is_admin();
|
||||
let password_reset_enabled = self.password_reset_enabled;
|
||||
html! {
|
||||
<div>
|
||||
{self.view_banner()}
|
||||
{self.view_banner(ctx)}
|
||||
<div class="container py-3 bg-kug">
|
||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||
<div class="py-3" style="max-width: 1000px">
|
||||
<Router<AppRoute>
|
||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
||||
<main class="py-3" style="max-width: 1000px">
|
||||
<Switch<AppRoute>
|
||||
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{self.view_footer()}
|
||||
</div>
|
||||
@ -117,56 +156,56 @@ impl Component for App {
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn get_redirect_route() -> Option<AppRoute> {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.is_empty()
|
||||
|| current_route == "/"
|
||||
|| current_route.contains("login")
|
||||
|| current_route.contains("reset-password")
|
||||
{
|
||||
None
|
||||
} else {
|
||||
use yew_router::Switch;
|
||||
AppRoute::from_route_part::<()>(current_route, None).0
|
||||
}
|
||||
// Get the page to land on after logging in, defaulting to the index.
|
||||
fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
|
||||
let route = ctx.link().history().unwrap().location().route::<AppRoute>();
|
||||
route.filter(|route| {
|
||||
!matches!(
|
||||
route,
|
||||
AppRoute::Index
|
||||
| AppRoute::Login
|
||||
| AppRoute::StartResetPassword
|
||||
| AppRoute::FinishResetPassword { token: _ }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_initial_redirections(&mut self) {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.contains("reset-password") {
|
||||
return;
|
||||
}
|
||||
match &self.user_info {
|
||||
None => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
fn apply_initial_redirections(&self, ctx: &Context<Self>) {
|
||||
let history = ctx.link().history().unwrap();
|
||||
let route = history.location().route::<AppRoute>();
|
||||
let redirection = match (route, &self.user_info, &self.redirect_to) {
|
||||
(
|
||||
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
|
||||
_,
|
||||
_,
|
||||
) if self.password_reset_enabled == Some(false) => Some(AppRoute::Login),
|
||||
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
|
||||
// User is logged in, a URL was given, don't redirect.
|
||||
(_, Some(_), Some(_)) => None,
|
||||
(_, Some((user_name, is_admin)), None) => {
|
||||
if *is_admin {
|
||||
Some(AppRoute::ListUsers)
|
||||
} else {
|
||||
Some(AppRoute::UserDetails {
|
||||
user_id: user_name.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Some((user_name, is_admin)) => match &self.redirect_to {
|
||||
Some(url) => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
|
||||
}
|
||||
None => {
|
||||
if *is_admin {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
|
||||
} else {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(
|
||||
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 {
|
||||
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! {
|
||||
<CreateUserForm/>
|
||||
@ -174,10 +213,10 @@ impl App {
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<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>
|
||||
{"Create a user"}
|
||||
</NavButton>
|
||||
</Link>
|
||||
</div>
|
||||
},
|
||||
AppRoute::CreateGroup => html! {
|
||||
@ -186,36 +225,45 @@ impl App {
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<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>
|
||||
{"Create a group"}
|
||||
</NavButton>
|
||||
</Link>
|
||||
</div>
|
||||
},
|
||||
AppRoute::GroupDetails(group_id) => html! {
|
||||
<GroupDetails group_id=group_id />
|
||||
AppRoute::GroupDetails { group_id } => html! {
|
||||
<GroupDetails group_id={*group_id} />
|
||||
},
|
||||
AppRoute::UserDetails(username) => html! {
|
||||
<UserDetails username=username is_admin=is_admin />
|
||||
AppRoute::UserDetails { user_id } => html! {
|
||||
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||
},
|
||||
AppRoute::ChangePassword(username) => html! {
|
||||
<ChangePasswordForm username=username is_admin=is_admin />
|
||||
AppRoute::ChangePassword { user_id } => html! {
|
||||
<ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
|
||||
},
|
||||
AppRoute::StartResetPassword => html! {
|
||||
<ResetPasswordStep1Form />
|
||||
AppRoute::StartResetPassword => match password_reset_enabled {
|
||||
Some(true) => html! { <ResetPasswordStep1Form /> },
|
||||
Some(false) => {
|
||||
html! { <Redirect to={AppRoute::Login}/> }
|
||||
}
|
||||
|
||||
None => html! {},
|
||||
},
|
||||
AppRoute::FinishResetPassword(token) => html! {
|
||||
<ResetPasswordStep2Form token=token />
|
||||
AppRoute::FinishResetPassword { token } => match password_reset_enabled {
|
||||
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! {
|
||||
<header class="p-2 mb-3 border-bottom">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||
<a href="/" class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-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>
|
||||
</a>
|
||||
|
||||
@ -224,16 +272,16 @@ impl App {
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 link-dark h6"
|
||||
route=AppRoute::ListUsers>
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListUsers}>
|
||||
<i class="bi-people me-2"></i>
|
||||
{"Users"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 link-dark h6"
|
||||
route=AppRoute::ListGroups>
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListGroups}>
|
||||
<i class="bi-collection me-2"></i>
|
||||
{"Groups"}
|
||||
</Link>
|
||||
@ -242,7 +290,7 @@ impl App {
|
||||
} } else { html!{} } }
|
||||
</ul>
|
||||
|
||||
<div class="dropdown text-end">
|
||||
/*<div class="dropdown text-end">
|
||||
<a href="#"
|
||||
class="d-block link-dark text-decoration-none dropdown-toggle"
|
||||
id="dropdownUser"
|
||||
@ -283,16 +331,64 @@ impl App {
|
||||
</li>
|
||||
</ul>
|
||||
} } else { html!{} } }
|
||||
</div>
|
||||
</div>*/
|
||||
{ self.view_user_menu(ctx) }
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_menu(&self, ctx: &Context<Self>) -> Html {
|
||||
if let Some((user_id, _)) = &self.user_info {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="dropdown text-end">
|
||||
<a href="#"
|
||||
class="d-block nav-link text-decoration-none dropdown-toggle"
|
||||
id="dropdownUser"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="currentColor"
|
||||
class="bi bi-person-circle"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
<span class="ms-2">
|
||||
{user_id}
|
||||
</span>
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||
aria-labelledby="dropdownUser1"
|
||||
style="">
|
||||
<li>
|
||||
<Link
|
||||
classes="dropdown-item"
|
||||
to={AppRoute::UserDetails{ user_id: user_id.clone() }}>
|
||||
{"View details"}
|
||||
</Link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<LogoutButton on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_footer(&self) -> Html {
|
||||
html! {
|
||||
<footer class="text-center text-muted fixed-bottom bg-light py-2">
|
||||
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||
<div>
|
||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||
</div>
|
||||
|
@ -1,34 +1,27 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
#[derive(PartialEq, Eq, Default)]
|
||||
enum OpaqueData {
|
||||
#[default]
|
||||
None,
|
||||
Login(opaque::client::login::ClientLogin),
|
||||
Registration(opaque::client::registration::ClientRegistration),
|
||||
}
|
||||
|
||||
impl Default for OpaqueData {
|
||||
fn default() -> Self {
|
||||
OpaqueData::None
|
||||
}
|
||||
}
|
||||
|
||||
impl OpaqueData {
|
||||
fn take(&mut self) -> Self {
|
||||
std::mem::take(self)
|
||||
@ -61,7 +54,6 @@ pub struct ChangePasswordForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
opaque_data: OpaqueData,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
@ -80,15 +72,20 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::FormUpdate => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
if self.common.is_admin {
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
if ctx.props().is_admin {
|
||||
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||
} else {
|
||||
let old_password = self.form.model().old_password;
|
||||
if old_password.is_empty() {
|
||||
@ -100,14 +97,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
.context("Could not initialize login")?;
|
||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username: self.common.username.clone(),
|
||||
username: ctx.props().username.clone(),
|
||||
login_start_request: login_start_request.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::login_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::login_start(req),
|
||||
Msg::AuthenticationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@ -119,17 +116,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
|e| {
|
||||
// Common error, we want to print a full error to the console but only a
|
||||
// simple one to the user.
|
||||
ConsoleService::error(&format!(
|
||||
"Invalid username or password: {}",
|
||||
e
|
||||
));
|
||||
error!(&format!("Invalid username or password: {}", e));
|
||||
anyhow!("Invalid username or password")
|
||||
},
|
||||
)?;
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
};
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||
}
|
||||
Msg::SubmitNewPassword => {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
@ -138,15 +132,15 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.common.username.clone(),
|
||||
username: ctx.props().username.clone(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_start(req),
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
@ -166,22 +160,20 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
);
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
}?;
|
||||
};
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
AppRoute::UserDetails(self.common.username.clone()),
|
||||
)));
|
||||
ctx.link().history().unwrap().push(AppRoute::UserDetails {
|
||||
user_id: ctx.props().username.clone(),
|
||||
});
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
@ -198,25 +190,21 @@ impl Component for ChangePasswordForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
ChangePasswordForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: OpaqueData::None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let is_admin = self.common.is_admin;
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let is_admin = ctx.props().is_admin;
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
@ -244,13 +232,14 @@ impl Component for ChangePasswordForm {
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="old_password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="current-password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("old_password")}
|
||||
</div>
|
||||
@ -266,14 +255,14 @@ impl Component for ChangePasswordForm {
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
@ -288,14 +277,14 @@ impl Component for ChangePasswordForm {
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
@ -305,17 +294,17 @@ impl Component for ChangePasswordForm {
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
<NavButton
|
||||
<Link
|
||||
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>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
@ -3,15 +3,12 @@ use crate::{
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@ -24,7 +21,6 @@ pub struct CreateGroup;
|
||||
|
||||
pub struct CreateGroupForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateGroupModel>,
|
||||
}
|
||||
|
||||
@ -41,7 +37,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
@ -53,6 +53,7 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
name: model.groupname,
|
||||
};
|
||||
self.common.call_graphql::<CreateGroup, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateGroupResponse,
|
||||
"Error trying to create group",
|
||||
@ -60,12 +61,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateGroupResponse(response) => {
|
||||
ConsoleService::log(&format!(
|
||||
log!(&format!(
|
||||
"Created group '{}'",
|
||||
&response?.create_group.display_name
|
||||
));
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@ -80,23 +80,19 @@ impl Component for CreateGroupForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<CreateGroupModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
@ -113,13 +109,13 @@ impl Component for CreateGroupForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="groupname"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="groupname"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("groupname")}
|
||||
</div>
|
||||
@ -129,8 +125,8 @@ impl Component for CreateGroupForm {
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Submit"}
|
||||
</button>
|
||||
|
@ -5,17 +5,14 @@ use crate::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{opaque, registration};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@ -28,7 +25,6 @@ pub struct CreateUser;
|
||||
|
||||
pub struct CreateUserForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateUserModel>,
|
||||
}
|
||||
|
||||
@ -73,7 +69,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
@ -93,6 +93,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateUserResponse,
|
||||
"Error trying to create user",
|
||||
@ -102,7 +103,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
Msg::CreateUserResponse(r) => {
|
||||
match r {
|
||||
Err(e) => return Err(e),
|
||||
Ok(r) => ConsoleService::log(&format!(
|
||||
Ok(r) => log!(&format!(
|
||||
"Created user '{}' at '{}'",
|
||||
&r.create_user.id, &r.create_user.creation_date
|
||||
)),
|
||||
@ -122,12 +123,11 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
registration_start_request: message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(HostService::register_start, req, move |r| {
|
||||
.call_backend(ctx, HostService::register_start(req), move |r| {
|
||||
Msg::RegistrationStartResponse((state, r))
|
||||
})
|
||||
.context("Error trying to create user")?;
|
||||
});
|
||||
} else {
|
||||
self.update(Msg::SuccessfulCreation);
|
||||
self.update(ctx, Msg::SuccessfulCreation);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@ -143,22 +143,19 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
server_data: response.server_data,
|
||||
registration_upload: registration_upload.message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
.context("Error trying to register user")?;
|
||||
self.common.call_backend(
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
response?;
|
||||
self.handle_msg(Msg::SuccessfulCreation)
|
||||
self.handle_msg(ctx, Msg::SuccessfulCreation)
|
||||
}
|
||||
Msg::SuccessfulCreation => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListUsers)));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListUsers);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@ -173,23 +170,19 @@ impl Component for CreateUserForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
type Field = yew_form::Field<CreateUserModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
@ -206,13 +199,13 @@ impl Component for CreateUserForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("username")}
|
||||
</div>
|
||||
@ -227,14 +220,14 @@ impl Component for CreateUserForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
input_type="email"
|
||||
field_name="email"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="email"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
@ -247,13 +240,13 @@ impl Component for CreateUserForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
autocomplete="name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="display_name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
@ -266,13 +259,13 @@ impl Component for CreateUserForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
autocomplete="given-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="first_name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
@ -285,13 +278,13 @@ impl Component for CreateUserForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
autocomplete="family-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="last_name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
@ -304,14 +297,14 @@ impl Component for CreateUserForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
input_type="password"
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
@ -324,14 +317,14 @@ impl Component for CreateUserForm {
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
input_type="password"
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
@ -340,9 +333,9 @@ impl Component for CreateUserForm {
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label mt-4"
|
||||
disabled=self.common.is_task_running()
|
||||
disabled={self.common.is_task_running()}
|
||||
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>
|
||||
{"Submit"}
|
||||
</button>
|
||||
|
@ -39,16 +39,21 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::ClickedDeleteGroup => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroup => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteGroupQuery, _>(
|
||||
ctx,
|
||||
delete_group_query::Variables {
|
||||
group_id: self.common.group.id,
|
||||
group_id: ctx.props().group.id,
|
||||
},
|
||||
Msg::DeleteGroupResponse,
|
||||
"Error trying to delete group",
|
||||
@ -58,12 +63,8 @@ impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
.on_group_deleted
|
||||
.emit(self.common.group.id);
|
||||
ctx.props().on_group_deleted.emit(ctx.props().group.id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@ -78,15 +79,15 @@ impl Component for DeleteGroup {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteGroupProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, first_render: bool) {
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
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(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteGroup)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteGroup {
|
||||
fn show_modal(&self) -> Html {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
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"
|
||||
aria-labelledby="deleteGroupModalLabel"
|
||||
aria-hidden="true"
|
||||
ref=self.node_ref.clone()>
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@ -141,25 +141,25 @@ impl DeleteGroup {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete group "}
|
||||
<b>{&self.common.group.display_name}</b>{"?"}
|
||||
<b>{&ctx.props().group.display_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteGroup)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
|
@ -36,16 +36,21 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::ClickedDeleteUser => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUser => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteUserQuery, _>(
|
||||
ctx,
|
||||
delete_user_query::Variables {
|
||||
user: self.common.username.clone(),
|
||||
user: ctx.props().username.clone(),
|
||||
},
|
||||
Msg::DeleteUserResponse,
|
||||
"Error trying to delete user",
|
||||
@ -55,12 +60,10 @@ impl CommonComponent<DeleteUser> for DeleteUser {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
ctx.props()
|
||||
.on_user_deleted
|
||||
.emit(self.common.username.clone());
|
||||
.emit(ctx.props().username.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@ -75,15 +78,15 @@ impl Component for DeleteUser {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteUserProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, first_render: bool) {
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
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(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteUser)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteUser {
|
||||
fn show_modal(&self) -> Html {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteUserModal".to_string() + &self.common.username
|
||||
id={"deleteUserModal".to_string() + &ctx.props().username}
|
||||
tabindex="-1"
|
||||
//role="dialog"
|
||||
aria-labelledby="deleteUserModalLabel"
|
||||
aria-hidden="true"
|
||||
ref=self.node_ref.clone()>
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog" /*role="document"*/>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@ -139,25 +141,25 @@ impl DeleteUser {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete user "}
|
||||
<b>{&self.common.username}</b>{"?"}
|
||||
<b>{&ctx.props().username}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteUser)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
|
@ -46,10 +46,11 @@ pub struct Props {
|
||||
}
|
||||
|
||||
impl GroupDetails {
|
||||
fn get_group_details(&mut self) {
|
||||
fn get_group_details(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetGroupDetails, _>(
|
||||
ctx,
|
||||
get_group_details::Variables {
|
||||
id: self.common.group_id,
|
||||
id: ctx.props().group_id,
|
||||
},
|
||||
Msg::GroupDetailsResponse,
|
||||
"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 user_id = user.id.clone();
|
||||
let display_name = user.display_name.clone();
|
||||
html! {
|
||||
<tr>
|
||||
<td>
|
||||
<Link route=AppRoute::UserDetails(user_id.clone())>
|
||||
<Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
|
||||
{user_id.clone()}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{display_name}</td>
|
||||
<td>
|
||||
<RemoveUserFromGroupComponent
|
||||
username=user_id
|
||||
group_id=g.id
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={user_id}
|
||||
group_id={g.id}
|
||||
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</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
|
||||
.users
|
||||
.iter()
|
||||
@ -170,16 +173,16 @@ impl GroupDetails {
|
||||
.collect();
|
||||
html! {
|
||||
<AddGroupMemberComponent
|
||||
group_id=g.id
|
||||
users=users
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
group_id={g.id}
|
||||
users={users}
|
||||
on_error={link.callback(Msg::OnError)}
|
||||
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
@ -215,24 +218,20 @@ impl Component for GroupDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group: None,
|
||||
};
|
||||
table.get_group_details();
|
||||
table.get_group_details(ctx);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.group, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
@ -240,8 +239,8 @@ impl Component for GroupDetails {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_details(u)}
|
||||
{self.view_user_list(u)}
|
||||
{self.view_add_user_button(u)}
|
||||
{self.view_user_list(ctx, u)}
|
||||
{self.view_add_user_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</div>
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::ListGroupsResponse(groups) => {
|
||||
self.groups = Some(groups?.groups.into_iter().collect());
|
||||
@ -58,12 +58,13 @@ impl Component for GroupTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = GroupTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
groups: None,
|
||||
};
|
||||
table.common.call_graphql::<GetGroupList, _>(
|
||||
ctx,
|
||||
get_group_list::Variables {},
|
||||
Msg::ListGroupsResponse,
|
||||
"Error trying to fetch groups",
|
||||
@ -71,18 +72,14 @@ impl Component for GroupTable {
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_groups()}
|
||||
{self.view_groups(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
@ -90,7 +87,7 @@ impl Component for GroupTable {
|
||||
}
|
||||
|
||||
impl GroupTable {
|
||||
fn view_groups(&self) -> Html {
|
||||
fn view_groups(&self, ctx: &Context<Self>) -> Html {
|
||||
let make_table = |groups: &Vec<Group>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
@ -103,7 +100,7 @@ impl GroupTable {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.iter().map(|u| self.view_group(u)).collect::<Vec<_>>()}
|
||||
{groups.iter().map(|u| self.view_group(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</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! {
|
||||
<tr key=group.id>
|
||||
<tr key={group.id}>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||
{&group.display_name}
|
||||
</Link>
|
||||
</td>
|
||||
@ -128,9 +126,9 @@ impl GroupTable {
|
||||
</td>
|
||||
<td>
|
||||
<DeleteGroup
|
||||
group=group.clone()
|
||||
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
group={group.clone()}
|
||||
on_group_deleted={link.callback(Msg::OnGroupDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
@ -1,14 +1,15 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
@ -30,6 +31,7 @@ pub struct FormModel {
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct Props {
|
||||
pub on_logged_in: Callback<(String, bool)>,
|
||||
pub password_reset_enabled: bool,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@ -46,7 +48,12 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@ -63,9 +70,9 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
login_start_request: message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(HostService::login_start, req, move |r| {
|
||||
.call_backend(ctx, HostService::login_start(req), move |r| {
|
||||
Msg::AuthenticationStartResponse((state, r))
|
||||
})?;
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
||||
@ -76,9 +83,8 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
Err(e) => {
|
||||
// Common error, we want to print a full error to the console but only a
|
||||
// simple one to the user.
|
||||
ConsoleService::error(&format!("Invalid username or password: {}", e));
|
||||
error!(&format!("Invalid username or password: {}", e));
|
||||
self.common.error = Some(anyhow!("Invalid username or password"));
|
||||
self.common.cancel_task();
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(l) => l,
|
||||
@ -88,24 +94,22 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
credential_finalization: login_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::login_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::login_finish(req),
|
||||
Msg::AuthenticationFinishResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::AuthenticationFinishResponse(user_info) => {
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
ctx.props()
|
||||
.on_logged_in
|
||||
.emit(user_info.context("Could not log in")?);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationRefreshResponse(user_info) => {
|
||||
self.refreshing = false;
|
||||
self.common.cancel_task();
|
||||
if let Ok(user_info) = user_info {
|
||||
self.common.on_logged_in.emit(user_info);
|
||||
ctx.props().on_logged_in.emit(user_info);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@ -121,32 +125,28 @@ impl Component for LoginForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut app = LoginForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
refreshing: true,
|
||||
};
|
||||
if let Err(e) =
|
||||
app.common
|
||||
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse)
|
||||
{
|
||||
ConsoleService::debug(&format!("Could not refresh auth: {}", e));
|
||||
app.refreshing = false;
|
||||
}
|
||||
app.common.call_backend(
|
||||
ctx,
|
||||
HostService::refresh(),
|
||||
Msg::AuthenticationRefreshResponse,
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
let password_reset_enabled = ctx.props().password_reset_enabled;
|
||||
let link = &ctx.link();
|
||||
if self.refreshing {
|
||||
html! {
|
||||
<div>
|
||||
@ -167,11 +167,11 @@ impl Component for LoginForm {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
@ -183,7 +183,7 @@ impl Component for LoginForm {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
@ -193,17 +193,23 @@ impl Component for LoginForm {
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-box-arrow-in-right me-2"/>
|
||||
{"Login"}
|
||||
</button>
|
||||
<NavButton
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::StartResetPassword>
|
||||
{"Forgot your password?"}
|
||||
</NavButton>
|
||||
{ if password_reset_enabled {
|
||||
html! {
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::StartResetPassword}>
|
||||
{"Forgot your password?"}
|
||||
</Link>
|
||||
}
|
||||
} else {
|
||||
html!{}
|
||||
}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
|
@ -21,16 +21,20 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::LogoutRequested => {
|
||||
self.common
|
||||
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
|
||||
.call_backend(ctx, HostService::logout(), Msg::LogoutCompleted);
|
||||
}
|
||||
Msg::LogoutCompleted(res) => {
|
||||
res?;
|
||||
delete_cookie("user_id")?;
|
||||
self.common.on_logged_out.emit(());
|
||||
ctx.props().on_logged_out.emit(());
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
@ -45,25 +49,22 @@ impl Component for LogoutButton {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
LogoutButton {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
||||
onclick={link.callback(|_| Msg::LogoutRequested)}>
|
||||
{"Logout"}
|
||||
</button>
|
||||
}
|
||||
|
@ -31,15 +31,18 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(ctx),
|
||||
Msg::RemoveGroupResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
ctx.props()
|
||||
.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)
|
||||
@ -51,11 +54,12 @@ impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupCompon
|
||||
}
|
||||
|
||||
impl RemoveUserFromGroupComponent {
|
||||
fn submit_remove_group(&mut self) {
|
||||
fn submit_remove_group(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
||||
ctx,
|
||||
remove_user_from_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
group: self.common.group_id,
|
||||
user: ctx.props().username.clone(),
|
||||
group: ctx.props().group_id,
|
||||
},
|
||||
Msg::RemoveGroupResponse,
|
||||
"Error trying to initiate removing the user from a group",
|
||||
@ -67,30 +71,28 @@ impl Component for RemoveUserFromGroupComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<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(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitRemoveGroup)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||
</button>
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
@ -31,7 +31,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||
}
|
||||
let FormModel { username } = self.form.model();
|
||||
self.common.call_backend(
|
||||
HostService::reset_password_step1,
|
||||
&username,
|
||||
ctx,
|
||||
HostService::reset_password_step1(username),
|
||||
Msg::PasswordResetResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::PasswordResetResponse(response) => {
|
||||
@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
ResetPasswordStep1Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
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;
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
@ -95,11 +96,11 @@ impl Component for ResetPasswordStep1Form {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username or email"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
{ if self.just_succeeded {
|
||||
html! {
|
||||
@ -111,17 +112,17 @@ impl Component for ResetPasswordStep1Form {
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-check-circle me-2"/>
|
||||
{"Reset password"}
|
||||
</button>
|
||||
<NavButton
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::Login>
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::Login}>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
|
@ -1,11 +1,11 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use lldap_auth::{
|
||||
opaque::client::registration as opaque_registration,
|
||||
password_reset::ServerPasswordResetResponse, registration,
|
||||
@ -14,10 +14,7 @@ use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
@ -33,7 +30,6 @@ pub struct ResetPasswordStep2Form {
|
||||
form: Form<FormModel>,
|
||||
username: Option<String>,
|
||||
opaque_data: Option<opaque_registration::ClientRegistration>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
@ -50,11 +46,15 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::ValidateTokenResponse(response) => {
|
||||
self.username = Some(response?.user_id);
|
||||
self.common.cancel_task();
|
||||
Ok(true)
|
||||
}
|
||||
Msg::FormUpdate => Ok(true),
|
||||
@ -73,10 +73,10 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
};
|
||||
self.opaque_data = Some(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_start(req),
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
@ -94,17 +94,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
|
||||
ctx.link().history().unwrap().push(AppRoute::Login);
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
@ -121,35 +119,28 @@ impl Component for ResetPasswordStep2Form {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = ResetPasswordStep2Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
username: None,
|
||||
};
|
||||
let token = component.common.token.clone();
|
||||
component
|
||||
.common
|
||||
.call_backend(
|
||||
HostService::reset_password_step2,
|
||||
&token,
|
||||
Msg::ValidateTokenResponse,
|
||||
)
|
||||
.unwrap();
|
||||
let token = ctx.props().token.clone();
|
||||
component.common.call_backend(
|
||||
ctx,
|
||||
HostService::reset_password_step2(token),
|
||||
Msg::ValidateTokenResponse,
|
||||
);
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
match (&self.username, &self.common.error) {
|
||||
(None, None) => {
|
||||
return html! {
|
||||
@ -158,9 +149,17 @@ impl Component for ResetPasswordStep2Form {
|
||||
}
|
||||
(None, Some(e)) => {
|
||||
return html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
<>
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</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>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
@ -198,14 +197,14 @@ impl Component for ResetPasswordStep2Form {
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
@ -215,8 +214,8 @@ impl Component for ResetPasswordStep2Form {
|
||||
<button
|
||||
class="btn btn-primary col-sm-1 col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -1,34 +1,30 @@
|
||||
use yew_router::{
|
||||
components::{RouterAnchor, RouterButton},
|
||||
Switch,
|
||||
};
|
||||
use yew_router::Routable;
|
||||
|
||||
#[derive(Switch, Debug, Clone)]
|
||||
#[derive(Routable, Debug, Clone, PartialEq)]
|
||||
pub enum AppRoute {
|
||||
#[to = "/login"]
|
||||
#[at("/login")]
|
||||
Login,
|
||||
#[to = "/reset-password/step1"]
|
||||
#[at("/reset-password/step1")]
|
||||
StartResetPassword,
|
||||
#[to = "/reset-password/step2/{token}"]
|
||||
FinishResetPassword(String),
|
||||
#[to = "/users/create"]
|
||||
#[at("/reset-password/step2/:token")]
|
||||
FinishResetPassword { token: String },
|
||||
#[at("/users/create")]
|
||||
CreateUser,
|
||||
#[to = "/users"]
|
||||
#[at("/users")]
|
||||
ListUsers,
|
||||
#[to = "/user/{user_id}/password"]
|
||||
ChangePassword(String),
|
||||
#[to = "/user/{user_id}"]
|
||||
UserDetails(String),
|
||||
#[to = "/groups/create"]
|
||||
#[at("/user/:user_id/password")]
|
||||
ChangePassword { user_id: String },
|
||||
#[at("/user/:user_id")]
|
||||
UserDetails { user_id: String },
|
||||
#[at("/groups/create")]
|
||||
CreateGroup,
|
||||
#[to = "/groups"]
|
||||
#[at("/groups")]
|
||||
ListGroups,
|
||||
#[to = "/group/{group_id}"]
|
||||
GroupDetails(i64),
|
||||
#[to = "/"]
|
||||
#[at("/group/:group_id")]
|
||||
GroupDetails { group_id: i64 },
|
||||
#[at("/")]
|
||||
Index,
|
||||
}
|
||||
|
||||
pub type Link = RouterAnchor<AppRoute>;
|
||||
|
||||
pub type NavButton = RouterButton<AppRoute>;
|
||||
pub type Link = yew_router::components::Link<AppRoute>;
|
||||
pub type Redirect = yew_router::components::Redirect<AppRoute>;
|
||||
|
@ -1,9 +1,6 @@
|
||||
use yew::{html::ChangeData, prelude::*};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
pub struct Select {
|
||||
link: ComponentLink<Self>,
|
||||
props: SelectProps,
|
||||
node_ref: NodeRef,
|
||||
}
|
||||
|
||||
@ -14,100 +11,70 @@ pub struct SelectProps {
|
||||
}
|
||||
|
||||
pub enum SelectMsg {
|
||||
OnSelectChange(ChangeData),
|
||||
OnSelectChange,
|
||||
}
|
||||
|
||||
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 {
|
||||
return None;
|
||||
}
|
||||
self.props
|
||||
ctx.props()
|
||||
.children
|
||||
.iter()
|
||||
.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();
|
||||
self.props
|
||||
ctx.props()
|
||||
.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 {
|
||||
type Message = SelectMsg;
|
||||
type Properties = SelectProps;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
node_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _first_render: bool) {
|
||||
self.send_selection_update();
|
||||
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
|
||||
self.send_selection_update(ctx);
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
let SelectMsg::OnSelectChange(data) = msg;
|
||||
match data {
|
||||
ChangeData::Select(_) => self.send_selection_update(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
fn update(&mut self, ctx: &Context<Self>, _: Self::Message) -> bool {
|
||||
self.send_selection_update(ctx);
|
||||
false
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.children.neq_assign(props.children)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<select class="form-select"
|
||||
ref=self.node_ref.clone()
|
||||
disabled=self.props.children.is_empty()
|
||||
onchange=self.link.callback(SelectMsg::OnSelectChange)>
|
||||
{ self.props.children.clone() }
|
||||
ref={self.node_ref.clone()}
|
||||
disabled={ctx.props().children.is_empty()}
|
||||
onchange={ctx.link().callback(|_| SelectMsg::OnSelectChange)}>
|
||||
{ ctx.props().children.clone() }
|
||||
</select>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SelectOption {
|
||||
props: SelectOptionProps,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct SelectOptionProps {
|
||||
pub value: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Component for SelectOption {
|
||||
type Message = ();
|
||||
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! {
|
||||
<option value=self.props.value.clone()>
|
||||
{&self.props.text}
|
||||
</option>
|
||||
}
|
||||
#[function_component(SelectOption)]
|
||||
pub fn select_option(props: &SelectOptionProps) -> Html {
|
||||
html! {
|
||||
<option value={props.value.clone()}>
|
||||
{&props.text}
|
||||
</option>
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ use crate::{
|
||||
components::{
|
||||
add_user_to_group::AddUserToGroupComponent,
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
router::{AppRoute, Link},
|
||||
user_details_form::UserDetailsForm,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
@ -47,7 +47,7 @@ pub struct Props {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::UserDetailsResponse(response) => match response {
|
||||
Ok(user) => self.user = Some(user.user),
|
||||
@ -77,10 +77,11 @@ impl CommonComponent<UserDetails> for UserDetails {
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn get_user_details(&mut self) {
|
||||
fn get_user_details(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetUserDetails, _>(
|
||||
ctx,
|
||||
get_user_details::Variables {
|
||||
id: self.common.username.clone(),
|
||||
id: ctx.props().username.clone(),
|
||||
},
|
||||
Msg::UserDetailsResponse,
|
||||
"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 display_name = group.display_name.clone();
|
||||
html! {
|
||||
<tr key="groupRow_".to_string() + &display_name>
|
||||
{if self.common.is_admin { html! {
|
||||
<tr key={"groupRow_".to_string() + &display_name}>
|
||||
{if ctx.props().is_admin { html! {
|
||||
<>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||
{&group.display_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<RemoveUserFromGroupComponent
|
||||
username=u.id.clone()
|
||||
group_id=group.id
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={u.id.clone()}
|
||||
group_id={group.id}
|
||||
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</>
|
||||
} } else { html! {
|
||||
@ -133,7 +135,7 @@ impl UserDetails {
|
||||
<thead>
|
||||
<tr key="headerRow">
|
||||
<th>{"Group"}</th>
|
||||
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
{ if ctx.props().is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -153,14 +155,15 @@ impl UserDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_add_group_button(&self, u: &User) -> Html {
|
||||
if self.common.is_admin {
|
||||
fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||
let link = &ctx.link();
|
||||
if ctx.props().is_admin {
|
||||
html! {
|
||||
<AddUserToGroupComponent
|
||||
username=u.id.clone()
|
||||
groups=u.groups.clone()
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
username={u.id.clone()}
|
||||
groups={u.groups.clone()}
|
||||
on_error={link.callback(Msg::OnError)}
|
||||
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
@ -172,24 +175,20 @@ impl Component for UserDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user: None,
|
||||
};
|
||||
table.get_user_details();
|
||||
table.get_user_details(ctx);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.user, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
@ -198,20 +197,19 @@ impl Component for UserDetails {
|
||||
<>
|
||||
<h3>{u.id.to_string()}</h3>
|
||||
<div class="d-flex flex-row-reverse">
|
||||
<NavButton
|
||||
route=AppRoute::ChangePassword(u.id.clone())
|
||||
<Link
|
||||
to={AppRoute::ChangePassword{user_id: u.id.clone()}}
|
||||
classes="btn btn-secondary">
|
||||
<i class="bi-key me-2"></i>
|
||||
{"Modify password"}
|
||||
</NavButton>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||
</div>
|
||||
<UserDetailsForm
|
||||
user=u.clone() />
|
||||
{self.view_group_memberships(u)}
|
||||
{self.view_add_group_button(u)}
|
||||
<UserDetailsForm user={u.clone()} />
|
||||
{self.view_group_memberships(ctx, u)}
|
||||
{self.view_add_group_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</>
|
||||
}
|
||||
|
@ -5,15 +5,19 @@ use crate::{
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use gloo_file::{
|
||||
callbacks::{read_as_bytes, FileReader},
|
||||
File,
|
||||
};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use wasm_bindgen::JsCast;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Default)]
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
file: Option<web_sys::File>,
|
||||
file: Option<File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
@ -21,7 +25,7 @@ impl ToString for JsFile {
|
||||
fn to_string(&self) -> String {
|
||||
self.file
|
||||
.as_ref()
|
||||
.map(web_sys::File::name)
|
||||
.map(File::name)
|
||||
.unwrap_or_else(String::new)
|
||||
}
|
||||
}
|
||||
@ -64,17 +68,21 @@ pub struct UserDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<UserModel>,
|
||||
avatar: JsFile,
|
||||
reader: Option<FileReader>,
|
||||
/// True if we just successfully updated the user, to display a success message.
|
||||
just_updated: bool,
|
||||
user: User,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
/// A form field changed.
|
||||
Update,
|
||||
/// A new file was selected.
|
||||
FileSelected(File),
|
||||
/// The "Submit" button was clicked.
|
||||
SubmitClicked,
|
||||
/// 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.
|
||||
UserUpdated(Result<update_user::ResponseData>),
|
||||
}
|
||||
@ -86,53 +94,47 @@ pub struct Props {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::Update => {
|
||||
let window = web_sys::window().expect("no global `window` exists");
|
||||
let document = window.document().expect("should have a document on window");
|
||||
let input = document
|
||||
.get_element_by_id("avatarInput")
|
||||
.expect("Form field avatarInput should be present")
|
||||
.dyn_into::<web_sys::HtmlInputElement>()
|
||||
.expect("Should be an HtmlInputElement");
|
||||
ConsoleService::log("Form update");
|
||||
if let Some(files) = input.files() {
|
||||
ConsoleService::log("Got file list");
|
||||
if files.length() > 0 {
|
||||
ConsoleService::log("Got a file");
|
||||
let new_avatar = JsFile {
|
||||
file: files.item(0),
|
||||
contents: None,
|
||||
};
|
||||
if self.avatar.file.as_ref().map(|f| f.name())
|
||||
!= new_avatar.file.as_ref().map(|f| f.name())
|
||||
{
|
||||
if let Some(ref file) = new_avatar.file {
|
||||
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
|
||||
}
|
||||
self.avatar = new_avatar;
|
||||
}
|
||||
}
|
||||
Msg::Update => Ok(true),
|
||||
Msg::FileSelected(new_avatar) => {
|
||||
if self.avatar.file.as_ref().map(|f| f.name()) != Some(new_avatar.name()) {
|
||||
let file_name = new_avatar.name();
|
||||
let link = ctx.link().clone();
|
||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||
link.send_message(Msg::FileLoaded(
|
||||
file_name,
|
||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||
))
|
||||
}));
|
||||
self.avatar = JsFile {
|
||||
file: Some(new_avatar),
|
||||
contents: None,
|
||||
};
|
||||
}
|
||||
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::FileLoaded(data) => {
|
||||
self.common.cancel_task();
|
||||
Msg::FileLoaded(file_name, data) => {
|
||||
if let Some(file) = &self.avatar.file {
|
||||
if file.name() == data.name {
|
||||
if !is_valid_jpeg(data.content.as_slice()) {
|
||||
if file.name() == file_name {
|
||||
let data = data?;
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = JsFile::default();
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
self.avatar.contents = Some(data.content);
|
||||
self.avatar.contents = Some(data);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reader = None;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@ -147,37 +149,36 @@ impl Component for UserDetailsForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let model = UserModel {
|
||||
email: props.user.email.clone(),
|
||||
display_name: props.user.display_name.clone(),
|
||||
first_name: props.user.first_name.clone(),
|
||||
last_name: props.user.last_name.clone(),
|
||||
email: ctx.props().user.email.clone(),
|
||||
display_name: ctx.props().user.display_name.clone(),
|
||||
first_name: ctx.props().user.first_name.clone(),
|
||||
last_name: ctx.props().user.last_name.clone(),
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::new(model),
|
||||
avatar: JsFile::default(),
|
||||
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;
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<UserModel>;
|
||||
let link = &ctx.link();
|
||||
|
||||
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
|
||||
let avatar_string = avatar_base64
|
||||
.as_deref()
|
||||
.or(self.common.user.avatar.as_deref())
|
||||
.or(self.user.avatar.as_deref())
|
||||
.unwrap_or("");
|
||||
html! {
|
||||
<div class="py-3">
|
||||
@ -188,7 +189,7 @@ impl Component for UserDetailsForm {
|
||||
{"User ID: "}
|
||||
</label>
|
||||
<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 class="form-group row mb-3">
|
||||
@ -197,7 +198,7 @@ impl Component for UserDetailsForm {
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<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 class="form-group row mb-3">
|
||||
@ -206,7 +207,7 @@ impl Component for UserDetailsForm {
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<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 class="form-group row mb-3">
|
||||
@ -221,10 +222,10 @@ impl Component for UserDetailsForm {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="email"
|
||||
autocomplete="email"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
@ -240,10 +241,10 @@ impl Component for UserDetailsForm {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
@ -257,10 +258,10 @@ impl Component for UserDetailsForm {
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
@ -274,10 +275,10 @@ impl Component for UserDetailsForm {
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
@ -296,7 +297,10 @@ impl Component for UserDetailsForm {
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
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 class="col-4">
|
||||
<img
|
||||
@ -312,8 +316,8 @@ impl Component for UserDetailsForm {
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
@ -328,7 +332,7 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div hidden=!self.just_updated>
|
||||
<div hidden={!self.just_updated}>
|
||||
<div class="alert alert-success mt-4">{"User successfully updated!"}</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -337,12 +341,10 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
|
||||
impl UserDetailsForm {
|
||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
||||
ConsoleService::log("Submit");
|
||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
if !self.form.validate() {
|
||||
bail!("Invalid inputs");
|
||||
}
|
||||
ConsoleService::log("Valid inputs");
|
||||
if let JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
@ -350,10 +352,9 @@ impl UserDetailsForm {
|
||||
{
|
||||
bail!("Image file hasn't finished loading, try again");
|
||||
}
|
||||
ConsoleService::log("File is correctly loaded");
|
||||
let base_user = &self.common.user;
|
||||
let base_user = &self.user;
|
||||
let mut user_input = update_user::UpdateUserInput {
|
||||
id: self.common.user.id.clone(),
|
||||
id: self.user.id.clone(),
|
||||
email: None,
|
||||
displayName: None,
|
||||
firstName: None,
|
||||
@ -378,12 +379,11 @@ impl UserDetailsForm {
|
||||
user_input.avatar = maybe_to_base64(&self.avatar)?;
|
||||
// Nothing changed.
|
||||
if user_input == default_user_input {
|
||||
ConsoleService::log("No changes");
|
||||
return Ok(false);
|
||||
}
|
||||
let req = update_user::Variables { user: user_input };
|
||||
ConsoleService::log("Querying");
|
||||
self.common.call_graphql::<UpdateUser, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::UserUpdated,
|
||||
"Error trying to update user",
|
||||
@ -392,23 +392,30 @@ impl UserDetailsForm {
|
||||
}
|
||||
|
||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||
self.common.cancel_task();
|
||||
match r {
|
||||
Err(e) => return Err(e),
|
||||
Ok(_) => {
|
||||
let model = self.form.model();
|
||||
self.common.user.email = model.email;
|
||||
self.common.user.display_name = model.display_name;
|
||||
self.common.user.first_name = model.first_name;
|
||||
self.common.user.last_name = model.last_name;
|
||||
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
|
||||
self.common.user.avatar = Some(avatar);
|
||||
}
|
||||
self.just_updated = true;
|
||||
}
|
||||
};
|
||||
r?;
|
||||
let model = self.form.model();
|
||||
self.user.email = model.email;
|
||||
self.user.display_name = model.display_name;
|
||||
self.user.first_name = model.first_name;
|
||||
self.user.last_name = model.last_name;
|
||||
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
|
||||
self.user.avatar = Some(avatar);
|
||||
}
|
||||
self.just_updated = true;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn upload_files(files: Option<FileList>) -> Msg {
|
||||
if let Some(files) = files {
|
||||
if files.length() > 0 {
|
||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||
} else {
|
||||
Msg::Update
|
||||
}
|
||||
} else {
|
||||
Msg::Update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||
|
@ -34,7 +34,7 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
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 {
|
||||
Msg::ListUsersResponse(users) => {
|
||||
self.users = Some(users?.users.into_iter().collect());
|
||||
@ -55,8 +55,9 @@ impl CommonComponent<UserTable> for 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, _>(
|
||||
ctx,
|
||||
list_users_query::Variables { filters: req },
|
||||
Msg::ListUsersResponse,
|
||||
"Error trying to fetch users",
|
||||
@ -68,27 +69,23 @@ impl Component for UserTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = UserTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
users: None,
|
||||
};
|
||||
table.get_users(None);
|
||||
table.get_users(ctx, None);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_users()}
|
||||
{self.view_users(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
@ -96,7 +93,7 @@ impl Component for UserTable {
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn view_users(&self) -> Html {
|
||||
fn view_users(&self, ctx: &Context<Self>) -> Html {
|
||||
let make_table = |users: &Vec<User>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
@ -113,7 +110,7 @@ impl UserTable {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.iter().map(|u| self.view_user(u)).collect::<Vec<_>>()}
|
||||
{users.iter().map(|u| self.view_user(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</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! {
|
||||
<tr key=user.id.clone()>
|
||||
<td><Link route=AppRoute::UserDetails(user.id.clone())>{&user.id}</Link></td>
|
||||
<tr key={user.id.clone()}>
|
||||
<td><Link to={AppRoute::UserDetails{user_id: user.id.clone()}}>{&user.id}</Link></td>
|
||||
<td>{&user.email}</td>
|
||||
<td>{&user.display_name}</td>
|
||||
<td>{&user.first_name}</td>
|
||||
@ -136,9 +134,9 @@ impl UserTable {
|
||||
<td>{&user.creation_date.naive_local().date()}</td>
|
||||
<td>
|
||||
<DeleteUser
|
||||
username=user.id.clone()
|
||||
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={user.id.clone()}
|
||||
on_user_deleted={link.callback(Msg::OnUserDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
@ -1,136 +1,84 @@
|
||||
use super::cookies::set_cookie;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gloo_net::http::{Method, Request};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{login, registration, JWTClaims};
|
||||
|
||||
use yew::callback::Callback;
|
||||
use yew::format::Json;
|
||||
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use web_sys::RequestCredentials;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HostService {}
|
||||
|
||||
fn get_default_options() -> FetchOptions {
|
||||
FetchOptions {
|
||||
credentials: Some(Credentials::SameOrigin),
|
||||
..FetchOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||
use jwt::*;
|
||||
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
||||
Ok(token.claims().clone())
|
||||
}
|
||||
|
||||
fn create_handler<Resp, CallbackResult, F>(
|
||||
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)
|
||||
})
|
||||
}
|
||||
const NO_BODY: Option<()> = None;
|
||||
|
||||
struct RequestBody<T>(T);
|
||||
|
||||
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))
|
||||
async fn call_server(
|
||||
url: &str,
|
||||
body: Option<impl Serialize>,
|
||||
error_message: &'static str,
|
||||
) -> Result<String> {
|
||||
let mut request = Request::new(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.credentials(RequestCredentials::SameOrigin);
|
||||
if let Some(b) = body {
|
||||
request = request
|
||||
.body(serde_json::to_string(&b)?)
|
||||
.method(Method::POST);
|
||||
}
|
||||
let response = request.send().await?;
|
||||
if response.ok() {
|
||||
Ok(response.text().await?)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"{}[{} {}]: {}",
|
||||
error_message,
|
||||
response.status(),
|
||||
response.status_text(),
|
||||
response.text().await?
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<yew::format::Nothing> for RequestBody<yew::format::Nothing> {
|
||||
fn from(request: yew::format::Nothing) -> Self {
|
||||
Self(request)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
fn call_server<Req, CallbackResult, F, RB>(
|
||||
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||
url: &str,
|
||||
request: RB,
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
request: Option<Body>,
|
||||
error_message: &'static str,
|
||||
parse_response: F,
|
||||
) -> Result<FetchTask>
|
||||
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")
|
||||
.body(request.into().0)?;
|
||||
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
|
||||
if status.is_success() {
|
||||
parse_response(data)
|
||||
} else {
|
||||
Err(anyhow!("{}[{}]: {}", error_message, status, data))
|
||||
}
|
||||
});
|
||||
FetchService::fetch_with_options(request, get_default_options(), handler)
|
||||
) -> Result<()> {
|
||||
call_server(url, request, error_message).await.map(|_| ())
|
||||
}
|
||||
|
||||
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,
|
||||
|_data: String| Ok(()),
|
||||
)
|
||||
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 {
|
||||
pub fn graphql_query<QueryType>(
|
||||
pub async fn graphql_query<QueryType>(
|
||||
variables: QueryType::Variables,
|
||||
callback: Callback<Result<QueryType::ResponseData>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
) -> Result<QueryType::ResponseData>
|
||||
where
|
||||
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);
|
||||
call_server(
|
||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||
"/api/graphql",
|
||||
&request_body,
|
||||
callback,
|
||||
Some(request_body),
|
||||
error_message,
|
||||
parse_graphql_response,
|
||||
)
|
||||
.await
|
||||
.and_then(unwrap_graphql_response)
|
||||
}
|
||||
|
||||
pub fn login_start(
|
||||
pub async fn login_start(
|
||||
request: login::ClientLoginStartRequest,
|
||||
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/login/start",
|
||||
&request,
|
||||
callback,
|
||||
Some(request),
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn login_finish(
|
||||
request: login::ClientLoginFinishRequest,
|
||||
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(
|
||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
"/auth/opaque/login/finish",
|
||||
&request,
|
||||
callback,
|
||||
Some(request),
|
||||
"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,
|
||||
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/register/start",
|
||||
&request,
|
||||
callback,
|
||||
Some(request),
|
||||
"Could not start registration: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn register_finish(
|
||||
pub async fn register_finish(
|
||||
request: registration::ClientRegistrationFinishRequest,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/opaque/register/finish",
|
||||
&request,
|
||||
callback,
|
||||
Some(request),
|
||||
"Could not finish registration",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
|
||||
let set_cookies = |jwt_claims: JWTClaims| {
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
pub async fn refresh() -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
"/auth/refresh",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
NO_BODY,
|
||||
"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.
|
||||
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/logout",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
"Could not logout",
|
||||
)
|
||||
pub async fn logout() -> Result<()> {
|
||||
call_server_empty_response_with_error_message("/auth/logout", NO_BODY, "Could not logout")
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn reset_password_step1(
|
||||
username: &str,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&format!("/auth/reset/step1/{}", url_escape::encode_query(username)),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
&format!("/auth/reset/step1/{}", url_escape::encode_query(&username)),
|
||||
NO_BODY,
|
||||
"Could not initiate password reset",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn reset_password_step2(
|
||||
token: &str,
|
||||
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
|
||||
) -> Result<FetchTask> {
|
||||
pub async fn reset_password_step2(
|
||||
token: String,
|
||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("/auth/reset/step2/{}", token),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
NO_BODY,
|
||||
"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
|
||||
//! take care of error and task handling.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
marker::PhantomData,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::infra::api::HostService;
|
||||
use anyhow::{Error, Result};
|
||||
use gloo_console::error;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{
|
||||
fetch::FetchTask,
|
||||
reader::{FileData, ReaderService, ReaderTask},
|
||||
ConsoleService,
|
||||
},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
/// Trait required for common components.
|
||||
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||
/// Handle the incoming message. If an error is returned here, any running task will be
|
||||
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
||||
/// component will be refreshed.
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
|
||||
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.
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||
}
|
||||
|
||||
enum AnyTask {
|
||||
None,
|
||||
FetchTask(FetchTask),
|
||||
ReaderTask(ReaderTask),
|
||||
}
|
||||
|
||||
impl AnyTask {
|
||||
fn is_some(&self) -> bool {
|
||||
!matches!(self, AnyTask::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<FetchTask>> for AnyTask {
|
||||
fn from(task: Option<FetchTask>) -> Self {
|
||||
match task {
|
||||
Some(t) => AnyTask::FetchTask(t),
|
||||
None => AnyTask::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structure that contains the common parts needed by most components.
|
||||
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
||||
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
||||
link: ComponentLink<C>,
|
||||
pub props: <C as Component>::Properties,
|
||||
pub error: Option<Error>,
|
||||
task: AnyTask,
|
||||
is_task_running: Arc<Mutex<bool>>,
|
||||
_phantom: PhantomData<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.
|
||||
pub fn is_task_running(&self) -> bool {
|
||||
self.task.is_some()
|
||||
}
|
||||
|
||||
/// Cancel any background task.
|
||||
pub fn cancel_task(&mut self) {
|
||||
self.task = AnyTask::None;
|
||||
}
|
||||
|
||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
error: None,
|
||||
task: AnyTask::None,
|
||||
}
|
||||
*self.is_task_running.lock().unwrap()
|
||||
}
|
||||
|
||||
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
||||
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
||||
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
|
||||
pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
|
||||
com.mut_common().error = None;
|
||||
match com.handle_msg(msg) {
|
||||
match com.handle_msg(ctx, msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
com.mut_common().error = Some(e);
|
||||
com.mut_common().cancel_task();
|
||||
assert!(!*com.mut_common().is_task_running.lock().unwrap());
|
||||
true
|
||||
}
|
||||
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.
|
||||
pub fn update_and_report_error(
|
||||
com: &mut C,
|
||||
ctx: &Context<C>,
|
||||
msg: <C as Component>::Message,
|
||||
report_fn: Callback<Error>,
|
||||
) -> ShouldRender {
|
||||
let should_render = Self::update(com, msg);
|
||||
) -> bool {
|
||||
let should_render = Self::update(com, ctx, msg);
|
||||
com.mut_common()
|
||||
.error
|
||||
.take()
|
||||
@ -126,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
.unwrap_or(should_render)
|
||||
}
|
||||
|
||||
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
|
||||
/// properties have changed and return whether the component should update.
|
||||
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
|
||||
where
|
||||
<C as yew::Component>::Properties: std::cmp::PartialEq,
|
||||
{
|
||||
self.props.neq_assign(props)
|
||||
}
|
||||
|
||||
/// Create a callback from the link.
|
||||
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
|
||||
where
|
||||
M: Into<C::Message>,
|
||||
F: Fn(IN) -> M + 'static,
|
||||
{
|
||||
self.link.callback(function)
|
||||
}
|
||||
|
||||
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
||||
/// result. Returns whether _starting the call_ failed.
|
||||
pub fn call_backend<M, Req, Cb, Resp>(
|
||||
&mut self,
|
||||
method: M,
|
||||
req: Req,
|
||||
callback: Cb,
|
||||
) -> Result<()>
|
||||
/// result.
|
||||
pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
|
||||
where
|
||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
||||
Fut: Future<Output = Resp> + '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.
|
||||
@ -165,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
/// `EnumCallback` should usually be left as `_`.
|
||||
pub fn call_graphql<QueryType, EnumCallback>(
|
||||
&mut self,
|
||||
ctx: &Context<C>,
|
||||
variables: QueryType::Variables,
|
||||
enum_callback: EnumCallback,
|
||||
error_message: &'static str,
|
||||
@ -172,41 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = HostService::graphql_query::<QueryType>(
|
||||
variables,
|
||||
self.link.callback(enum_callback),
|
||||
error_message,
|
||||
)
|
||||
.map_err::<(), _>(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
self.error = Some(e);
|
||||
})
|
||||
.ok()
|
||||
.into();
|
||||
}
|
||||
|
||||
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
|
||||
where
|
||||
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = AnyTask::ReaderTask(ReaderService::read_file(
|
||||
file,
|
||||
self.link.callback_once(callback),
|
||||
)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
|
||||
type Target = <C as Component>::Properties;
|
||||
|
||||
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
|
||||
&self.props
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
|
||||
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
|
||||
&mut self.props
|
||||
self.call_backend(
|
||||
ctx,
|
||||
HostService::graphql_query::<QueryType>(variables, error_message),
|
||||
enum_callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,16 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "bootstrap")]
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen]
|
||||
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||
pub type Modal;
|
||||
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||
pub fn new(e: web_sys::Element) -> Modal;
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||
pub fn show(this: &Modal);
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||
pub fn hide(this: &Modal);
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
#![recursion_limit = "256"]
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::nonstandard_macro_braces)]
|
||||
#![allow(clippy::uninlined_format_args)]
|
||||
#![allow(clippy::let_unit_value)]
|
||||
|
||||
pub mod components;
|
||||
pub mod infra;
|
||||
|
||||
@ -8,7 +10,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run_app() -> Result<(), JsValue> {
|
||||
yew::start_app::<components::app::App>();
|
||||
yew::start_app::<components::app::AppContainer>();
|
||||
|
||||
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-icons@1.5.0/font/bootstrap-icons.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() {
|
||||
await init('/pkg/lldap_app_bg.wasm');
|
||||
run_app();
|
@ -10,3 +10,23 @@ header h2 {
|
||||
font-weight: 700;
|
||||
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 ![]() (image error) 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.
|
||||
|
||||

|
||||
|
||||
#### 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`
|
||||
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
|
||||
* 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`
|
||||
* First Name Attribute: `givenName`
|
||||
* Surname Attribute: `sn`
|
||||
* Email Attribute: `mail`
|
||||
* Avatar Attribute: `jpegPhoto`
|
||||
* Check `Enable User Synchronization`
|
||||
|
||||
Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||
|
||||
After applying the above settings, users should be able to log in with either their user name or email address.
|
||||
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 ![]() (image error) Size: 148 KiB |
@ -35,6 +35,12 @@ Otherwise, just use:
|
||||
```
|
||||
(uid=*)
|
||||
```
|
||||
### Admin Base DN
|
||||
|
||||
The DN of your admin group. If you have `media_admin` as your group you would use:
|
||||
```
|
||||
cn=media_admin,ou=groups,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Admin Filter
|
||||
|
||||
|
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
|
||||
|
||||

|
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"
|
||||
|
||||
## Database URL.
|
||||
## This encodes the type of database (SQlite, Mysql and so
|
||||
## on), the path, the user, password, and sometimes the mode (when
|
||||
## This encodes the type of database (SQlite, MySQL, or PostgreSQL)
|
||||
## , the path, the user, password, and sometimes the mode (when
|
||||
## relevant).
|
||||
## Note: Currently, only SQlite is supported. SQlite should come with
|
||||
## "?mode=rwc" to create the DB if not present.
|
||||
## Note: SQlite should come with "?mode=rwc" to create the DB
|
||||
## if not present.
|
||||
## Example URLs:
|
||||
## - "postgres://postgres-user:password@postgres-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"
|
||||
|
||||
## Private key file.
|
||||
@ -113,7 +113,7 @@ key_file = "/data/private_key"
|
||||
#server="smtp.gmail.com"
|
||||
## The SMTP port.
|
||||
#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"
|
||||
## The SMTP user, usually your email address.
|
||||
#user="sender@gmail.com"
|
||||
|
@ -1,3 +1,5 @@
|
||||
#![allow(clippy::uninlined_format_args)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
@ -5,47 +5,47 @@ name = "lldap"
|
||||
version = "0.4.2-alpha"
|
||||
|
||||
[dependencies]
|
||||
actix = "0.12"
|
||||
actix-files = "0.6.0-beta.6"
|
||||
actix-http = "=3.0.0-beta.9"
|
||||
actix-rt = "2.2.0"
|
||||
actix-server = "=2.0.0-beta.5"
|
||||
actix-service = "2.0.0"
|
||||
actix-web = "=4.0.0-beta.8"
|
||||
actix-web-httpauth = "0.6.0-beta.2"
|
||||
actix = "0.13"
|
||||
actix-files = "0.6"
|
||||
actix-http = "3"
|
||||
actix-rt = "2"
|
||||
actix-server = "2"
|
||||
actix-service = "2"
|
||||
actix-web = "4.3"
|
||||
actix-web-httpauth = "0.8"
|
||||
anyhow = "*"
|
||||
async-trait = "0.1"
|
||||
base64 = "0.13"
|
||||
base64 = "*"
|
||||
bincode = "1.3"
|
||||
cron = "*"
|
||||
derive_builder = "0.10.2"
|
||||
derive_builder = "0.12"
|
||||
figment_file_provider_adapter = "0.1"
|
||||
futures = "*"
|
||||
futures-util = "*"
|
||||
hmac = "0.10"
|
||||
hmac = "0.12"
|
||||
http = "*"
|
||||
itertools = "0.10.1"
|
||||
juniper = "0.15.10"
|
||||
juniper_actix = "0.4.0"
|
||||
jwt = "0.13"
|
||||
ldap3_proto = "*"
|
||||
itertools = "0.10"
|
||||
juniper = "0.15"
|
||||
jwt = "0.16"
|
||||
lber = "0.4.1"
|
||||
ldap3_proto = ">=0.3.1"
|
||||
log = "*"
|
||||
orion = "0.16"
|
||||
orion = "0.17"
|
||||
rustls = "0.20"
|
||||
serde = "*"
|
||||
serde_json = "1"
|
||||
sha2 = "0.9"
|
||||
sha2 = "0.10"
|
||||
thiserror = "*"
|
||||
time = "0.2"
|
||||
time = "0.3"
|
||||
tokio-rustls = "0.23"
|
||||
tokio-stream = "*"
|
||||
tokio-util = "0.7.3"
|
||||
tokio-util = "0.7"
|
||||
tracing = "*"
|
||||
tracing-actix-web = "0.4.0-beta.7"
|
||||
tracing-actix-web = "0.7"
|
||||
tracing-attributes = "^0.1.21"
|
||||
tracing-log = "*"
|
||||
rustls-pemfile = "1.0.0"
|
||||
serde_bytes = "0.11.7"
|
||||
rustls-pemfile = "1"
|
||||
serde_bytes = "0.11"
|
||||
webpki-roots = "*"
|
||||
|
||||
[dependencies.chrono]
|
||||
@ -54,7 +54,7 @@ version = "*"
|
||||
|
||||
[dependencies.clap]
|
||||
features = ["std", "color", "suggestions", "derive", "env"]
|
||||
version = "3.1.15"
|
||||
version = "4"
|
||||
|
||||
[dependencies.figment]
|
||||
features = ["env", "toml"]
|
||||
@ -67,15 +67,11 @@ features = ["env-filter", "tracing-log"]
|
||||
[dependencies.lettre]
|
||||
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
|
||||
default-features = false
|
||||
version = "0.10.0-rc.3"
|
||||
version = "0.10.1"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
|
||||
[dependencies.sea-query]
|
||||
version = "*"
|
||||
features = ["with-chrono"]
|
||||
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.6"
|
||||
|
||||
@ -89,7 +85,7 @@ version = "*"
|
||||
|
||||
[dependencies.tokio]
|
||||
features = ["full"]
|
||||
version = "1.17"
|
||||
version = "1.25"
|
||||
|
||||
[dependencies.uuid]
|
||||
features = ["v3"]
|
||||
@ -101,7 +97,7 @@ version = "^0.1.4"
|
||||
|
||||
[dependencies.actix-tls]
|
||||
features = ["default", "rustls"]
|
||||
version = "=3.0.0-beta.5"
|
||||
version = "3"
|
||||
|
||||
[dependencies.image]
|
||||
features = ["jpeg"]
|
||||
@ -109,7 +105,7 @@ default-features = false
|
||||
version = "0.24"
|
||||
|
||||
[dependencies.sea-orm]
|
||||
version= "0.10.3"
|
||||
version= "0.11"
|
||||
default-features = false
|
||||
features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"]
|
||||
|
||||
@ -119,4 +115,4 @@ default-features = false
|
||||
features = ["rustls-tls-webpki-roots"]
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = "0.9.1"
|
||||
mockall = "0.11"
|
||||
|
@ -14,31 +14,85 @@ pub struct BindRequest {
|
||||
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)]
|
||||
pub enum UserRequestFilter {
|
||||
And(Vec<UserRequestFilter>),
|
||||
Or(Vec<UserRequestFilter>),
|
||||
Not(Box<UserRequestFilter>),
|
||||
UserId(UserId),
|
||||
UserIdSubString(SubStringFilter),
|
||||
Equality(UserColumn, String),
|
||||
SubString(UserColumn, SubStringFilter),
|
||||
// Check if a user belongs to a group identified by name.
|
||||
MemberOf(String),
|
||||
// Same, by id.
|
||||
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)]
|
||||
pub enum GroupRequestFilter {
|
||||
And(Vec<GroupRequestFilter>),
|
||||
Or(Vec<GroupRequestFilter>),
|
||||
Not(Box<GroupRequestFilter>),
|
||||
DisplayName(String),
|
||||
DisplayNameSubString(SubStringFilter),
|
||||
Uuid(Uuid),
|
||||
GroupId(GroupId),
|
||||
// Check if the group contains a user identified by uid.
|
||||
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)]
|
||||
pub struct CreateUserRequest {
|
||||
// Same fields as User, but no creation_date, and with password.
|
||||
@ -68,13 +122,17 @@ pub struct UpdateGroupRequest {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait LoginHandler: Clone + Send {
|
||||
pub trait LoginHandler: Send + Sync {
|
||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupBackendHandler {
|
||||
pub trait GroupListerBackendHandler {
|
||||
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 update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
@ -82,12 +140,16 @@ pub trait GroupBackendHandler {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserBackendHandler {
|
||||
pub trait UserListerBackendHandler {
|
||||
async fn list_users(
|
||||
&self,
|
||||
filters: Option<UserRequestFilter>,
|
||||
get_groups: bool,
|
||||
) -> Result<Vec<UserAndGroups>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserBackendHandler {
|
||||
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<()>;
|
||||
@ -98,7 +160,15 @@ pub trait UserBackendHandler {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BackendHandler: Clone + Send + GroupBackendHandler + UserBackendHandler {}
|
||||
pub trait BackendHandler:
|
||||
Send
|
||||
+ Sync
|
||||
+ GroupBackendHandler
|
||||
+ UserBackendHandler
|
||||
+ UserListerBackendHandler
|
||||
+ GroupListerBackendHandler
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mockall::mock! {
|
||||
@ -107,16 +177,22 @@ mockall::mock! {
|
||||
fn clone(&self) -> Self;
|
||||
}
|
||||
#[async_trait]
|
||||
impl GroupBackendHandler for TestBackendHandler {
|
||||
impl GroupListerBackendHandler for TestBackendHandler {
|
||||
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 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 {
|
||||
impl UserListerBackendHandler for TestBackendHandler {
|
||||
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 create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
@ -135,13 +211,21 @@ mockall::mock! {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine;
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_uuid_time() {
|
||||
use chrono::prelude::*;
|
||||
let user_id = "bob";
|
||||
let date1 = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 11).unwrap();
|
||||
let date2 = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 12).unwrap();
|
||||
let date1 = Utc
|
||||
.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!(
|
||||
Uuid::from_name_and_date(user_id, &date1),
|
||||
Uuid::from_name_and_date(user_id, &date2)
|
||||
@ -151,7 +235,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_jpeg_try_from_bytes() {
|
||||
let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k=";
|
||||
let base64_jpeg = base64::decode(base64_raw).unwrap();
|
||||
let base64_jpeg = base64::engine::general_purpose::STANDARD
|
||||
.decode(base64_raw)
|
||||
.unwrap();
|
||||
JpegPhoto::try_from(base64_jpeg).unwrap();
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
use ldap3_proto::{
|
||||
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
|
||||
};
|
||||
use tracing::{debug, info, instrument, warn};
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::domain::{
|
||||
handler::{BackendHandler, GroupRequestFilter},
|
||||
handler::{GroupListerBackendHandler, GroupRequestFilter},
|
||||
ldap::error::LdapError,
|
||||
types::{Group, GroupColumn, UserId, Uuid},
|
||||
};
|
||||
@ -12,15 +12,16 @@ use crate::domain::{
|
||||
use super::{
|
||||
error::LdapResult,
|
||||
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,
|
||||
base_dn_str: &str,
|
||||
attribute: &str,
|
||||
user_filter: &Option<&UserId>,
|
||||
user_filter: &Option<UserId>,
|
||||
ignored_group_attributes: &[String],
|
||||
) -> Option<Vec<Vec<u8>>> {
|
||||
let attribute = attribute.to_ascii_lowercase();
|
||||
@ -28,12 +29,12 @@ fn get_group_attribute(
|
||||
"objectclass" => vec![b"groupOfUniqueNames".to_vec()],
|
||||
// Always returned as part of the base response.
|
||||
"dn" | "distinguishedname" => return None,
|
||||
"cn" | "uid" => vec![group.display_name.clone().into_bytes()],
|
||||
"entryuuid" => vec![group.uuid.to_string().into_bytes()],
|
||||
"cn" | "uid" | "id" => vec![group.display_name.clone().into_bytes()],
|
||||
"entryuuid" | "uuid" => vec![group.uuid.to_string().into_bytes()],
|
||||
"member" | "uniquemember" => group
|
||||
.users
|
||||
.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())
|
||||
.collect(),
|
||||
"1.1" => return None,
|
||||
@ -72,14 +73,18 @@ const ALL_GROUP_ATTRIBUTE_KEYS: &[&str] = &[
|
||||
"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(
|
||||
group: Group,
|
||||
base_dn_str: &str,
|
||||
attributes: &[String],
|
||||
user_filter: &Option<&UserId>,
|
||||
user_filter: &Option<UserId>,
|
||||
ignored_group_attributes: &[String],
|
||||
) -> LdapSearchResultEntry {
|
||||
let expanded_attributes = expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS);
|
||||
let expanded_attributes = expand_group_attribute_wildcards(attributes);
|
||||
|
||||
LdapSearchResultEntry {
|
||||
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
|
||||
@ -120,12 +125,20 @@ fn convert_group_filter(
|
||||
)?;
|
||||
Ok(GroupRequestFilter::Member(user_name))
|
||||
}
|
||||
"objectclass" => match value.as_str() {
|
||||
"groupofuniquenames" | "groupofnames" => Ok(GroupRequestFilter::And(vec![])),
|
||||
_ => Ok(GroupRequestFilter::Not(Box::new(GroupRequestFilter::And(
|
||||
vec![],
|
||||
)))),
|
||||
},
|
||||
"objectclass" => Ok(GroupRequestFilter::from(matches!(
|
||||
value.as_str(),
|
||||
"groupofuniquenames" | "groupofnames"
|
||||
))),
|
||||
"dn" => Ok(get_group_id_from_distinguished_name(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)
|
||||
.map(GroupRequestFilter::DisplayName)
|
||||
.unwrap_or_else(|_| {
|
||||
warn!("Invalid dn filter on group: {}", value);
|
||||
GroupRequestFilter::from(false)
|
||||
})),
|
||||
_ => match map_group_field(field) {
|
||||
Some(GroupColumn::DisplayName) => {
|
||||
Ok(GroupRequestFilter::DisplayName(value.to_string()))
|
||||
@ -144,9 +157,7 @@ fn convert_group_filter(
|
||||
field
|
||||
);
|
||||
}
|
||||
Ok(GroupRequestFilter::Not(Box::new(GroupRequestFilter::And(
|
||||
vec![],
|
||||
))))
|
||||
Ok(GroupRequestFilter::from(false))
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -160,16 +171,26 @@ fn convert_group_filter(
|
||||
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
|
||||
LdapFilter::Present(field) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
if field == "objectclass"
|
||||
|| field == "dn"
|
||||
|| field == "distinguishedname"
|
||||
|| map_group_field(field).is_some()
|
||||
{
|
||||
Ok(GroupRequestFilter::And(vec![]))
|
||||
} else {
|
||||
Ok(GroupRequestFilter::Not(Box::new(GroupRequestFilter::And(
|
||||
vec![],
|
||||
))))
|
||||
Ok(GroupRequestFilter::from(
|
||||
field == "objectclass"
|
||||
|| field == "dn"
|
||||
|| field == "distinguishedname"
|
||||
|| map_group_field(field).is_some(),
|
||||
))
|
||||
}
|
||||
LdapFilter::Substring(field, substring_filter) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
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 {
|
||||
@ -180,42 +201,37 @@ fn convert_group_filter(
|
||||
}
|
||||
|
||||
#[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_filter: &LdapFilter,
|
||||
attributes: &[String],
|
||||
base: &str,
|
||||
user_filter: &Option<&UserId>,
|
||||
backend: &mut Backend,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
backend: &Backend,
|
||||
) -> LdapResult<Vec<Group>> {
|
||||
debug!(?ldap_filter);
|
||||
let filter = convert_group_filter(ldap_info, ldap_filter)?;
|
||||
let parsed_filters = match user_filter {
|
||||
None => filter,
|
||||
Some(u) => {
|
||||
info!("Unprivileged search, limiting results");
|
||||
GroupRequestFilter::And(vec![filter, GroupRequestFilter::Member((*u).clone())])
|
||||
}
|
||||
};
|
||||
debug!(?parsed_filters);
|
||||
let groups = backend
|
||||
.list_groups(Some(parsed_filters))
|
||||
let filters = convert_group_filter(ldap_info, ldap_filter)?;
|
||||
debug!(?filters);
|
||||
backend
|
||||
.list_groups(Some(filters))
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::Other,
|
||||
message: format!(r#"Error while listing groups "{}": {:#}"#, base, e),
|
||||
})?;
|
||||
|
||||
Ok(groups
|
||||
.into_iter()
|
||||
.map(|u| {
|
||||
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
|
||||
u,
|
||||
&ldap_info.base_dn_str,
|
||||
attributes,
|
||||
user_filter,
|
||||
&ldap_info.ignored_group_attributes,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn convert_groups_to_ldap_op<'a>(
|
||||
groups: Vec<Group>,
|
||||
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(
|
||||
g,
|
||||
&ldap_info.base_dn_str,
|
||||
attributes,
|
||||
user_filter,
|
||||
&ldap_info.ignored_group_attributes,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
@ -1,12 +1,16 @@
|
||||
use chrono::TimeZone;
|
||||
use ldap3_proto::{
|
||||
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
|
||||
};
|
||||
use tracing::{debug, info, instrument, warn};
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::domain::{
|
||||
handler::{BackendHandler, UserRequestFilter},
|
||||
ldap::{error::LdapError, utils::expand_attribute_wildcards},
|
||||
types::{GroupDetails, User, UserColumn, UserId},
|
||||
handler::{UserListerBackendHandler, UserRequestFilter},
|
||||
ldap::{
|
||||
error::LdapError,
|
||||
utils::{expand_attribute_wildcards, get_user_id_from_distinguished_name},
|
||||
},
|
||||
types::{GroupDetails, User, UserAndGroups, UserColumn, UserId},
|
||||
};
|
||||
|
||||
use super::{
|
||||
@ -14,7 +18,7 @@ use super::{
|
||||
utils::{get_group_id_from_distinguished_name, map_user_field, LdapInfo},
|
||||
};
|
||||
|
||||
fn get_user_attribute(
|
||||
pub fn get_user_attribute(
|
||||
user: &User,
|
||||
attribute: &str,
|
||||
base_dn_str: &str,
|
||||
@ -31,25 +35,26 @@ fn get_user_attribute(
|
||||
],
|
||||
// dn is always returned as part of the base response.
|
||||
"dn" | "distinguishedname" => return None,
|
||||
"uid" => vec![user.user_id.to_string().into_bytes()],
|
||||
"entryuuid" => vec![user.uuid.to_string().into_bytes()],
|
||||
"mail" => vec![user.email.clone().into_bytes()],
|
||||
"givenname" => vec![user.first_name.clone()?.into_bytes()],
|
||||
"sn" => vec![user.last_name.clone()?.into_bytes()],
|
||||
"jpegphoto" => vec![user.avatar.clone()?.into_bytes()],
|
||||
"uid" | "user_id" | "id" => vec![user.user_id.to_string().into_bytes()],
|
||||
"entryuuid" | "uuid" => vec![user.uuid.to_string().into_bytes()],
|
||||
"mail" | "email" => vec![user.email.clone().into_bytes()],
|
||||
"givenname" | "first_name" | "firstname" => vec![user.first_name.clone()?.into_bytes()],
|
||||
"sn" | "last_name" | "lastname" => vec![user.last_name.clone()?.into_bytes()],
|
||||
"jpegphoto" | "avatar" => vec![user.avatar.clone()?.into_bytes()],
|
||||
"memberof" => groups
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|id_and_name| {
|
||||
format!(
|
||||
"uid={},ou=groups,{}",
|
||||
&id_and_name.display_name, base_dn_str
|
||||
)
|
||||
.into_bytes()
|
||||
format!("cn={},ou=groups,{}", &id_and_name.display_name, base_dn_str).into_bytes()
|
||||
})
|
||||
.collect(),
|
||||
"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,
|
||||
// We ignore the operational attribute wildcard.
|
||||
"+" => return None,
|
||||
@ -92,15 +97,15 @@ const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[
|
||||
fn make_ldap_search_user_result_entry(
|
||||
user: User,
|
||||
base_dn_str: &str,
|
||||
attributes: &[&str],
|
||||
attributes: &[String],
|
||||
groups: Option<&[GroupDetails]>,
|
||||
ignored_user_attributes: &[String],
|
||||
) -> LdapSearchResultEntry {
|
||||
let expanded_attributes = expand_user_attribute_wildcards(attributes);
|
||||
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
|
||||
|
||||
LdapSearchResultEntry {
|
||||
dn,
|
||||
attributes: attributes
|
||||
attributes: expanded_attributes
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
let values =
|
||||
@ -127,22 +132,27 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
|
||||
LdapFilter::Equality(field, value) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
match field.as_str() {
|
||||
"memberof" => {
|
||||
let group_name = get_group_id_from_distinguished_name(
|
||||
"memberof" => Ok(UserRequestFilter::MemberOf(
|
||||
get_group_id_from_distinguished_name(
|
||||
&value.to_ascii_lowercase(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)?;
|
||||
Ok(UserRequestFilter::MemberOf(group_name))
|
||||
}
|
||||
"objectclass" => match value.to_ascii_lowercase().as_str() {
|
||||
"person" | "inetorgperson" | "posixaccount" | "mailaccount" => {
|
||||
Ok(UserRequestFilter::And(vec![]))
|
||||
}
|
||||
_ => Ok(UserRequestFilter::Not(Box::new(UserRequestFilter::And(
|
||||
vec![],
|
||||
)))),
|
||||
},
|
||||
)?,
|
||||
)),
|
||||
"objectclass" => Ok(UserRequestFilter::from(matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
|
||||
))),
|
||||
"dn" => Ok(get_user_id_from_distinguished_name(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
)
|
||||
.map(UserRequestFilter::UserId)
|
||||
.unwrap_or_else(|_| {
|
||||
warn!("Invalid dn filter on user: {}", value);
|
||||
UserRequestFilter::from(false)
|
||||
})),
|
||||
_ => match map_user_field(field) {
|
||||
Some(UserColumn::UserId) => Ok(UserRequestFilter::UserId(UserId::new(value))),
|
||||
Some(field) => Ok(UserRequestFilter::Equality(field, value.clone())),
|
||||
@ -154,9 +164,7 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
|
||||
field
|
||||
);
|
||||
}
|
||||
Ok(UserRequestFilter::Not(Box::new(UserRequestFilter::And(
|
||||
vec![],
|
||||
))))
|
||||
Ok(UserRequestFilter::from(false))
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -164,16 +172,33 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
|
||||
LdapFilter::Present(field) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
// Check that it's a field we support.
|
||||
if field == "objectclass"
|
||||
|| field == "dn"
|
||||
|| field == "distinguishedname"
|
||||
|| map_user_field(field).is_some()
|
||||
{
|
||||
Ok(UserRequestFilter::And(vec![]))
|
||||
} else {
|
||||
Ok(UserRequestFilter::Not(Box::new(UserRequestFilter::And(
|
||||
vec![],
|
||||
))))
|
||||
Ok(UserRequestFilter::from(
|
||||
field == "objectclass"
|
||||
|| field == "dn"
|
||||
|| field == "distinguishedname"
|
||||
|| map_user_field(field).is_some(),
|
||||
))
|
||||
}
|
||||
LdapFilter::Substring(field, substring_filter) => {
|
||||
let field = &field.to_ascii_lowercase();
|
||||
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 {
|
||||
@ -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")]
|
||||
pub async fn get_user_list<Backend: BackendHandler>(
|
||||
pub async fn get_user_list<Backend: UserListerBackendHandler>(
|
||||
ldap_info: &LdapInfo,
|
||||
ldap_filter: &LdapFilter,
|
||||
attributes: &[String],
|
||||
request_groups: bool,
|
||||
base: &str,
|
||||
user_filter: &Option<&UserId>,
|
||||
backend: &mut Backend,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
backend: &Backend,
|
||||
) -> LdapResult<Vec<UserAndGroups>> {
|
||||
debug!(?ldap_filter);
|
||||
let filters = convert_user_filter(ldap_info, ldap_filter)?;
|
||||
let parsed_filters = match user_filter {
|
||||
None => filters,
|
||||
Some(u) => {
|
||||
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)
|
||||
debug!(?filters);
|
||||
backend
|
||||
.list_users(Some(filters), request_groups)
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::Other,
|
||||
message: format!(r#"Error while searching user "{}": {:#}"#, base, e),
|
||||
})?;
|
||||
|
||||
Ok(users
|
||||
.into_iter()
|
||||
.map(|u| {
|
||||
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
|
||||
u.user,
|
||||
&ldap_info.base_dn_str,
|
||||
&expanded_attributes,
|
||||
u.groups.as_deref(),
|
||||
&ldap_info.ignored_user_attributes,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn convert_users_to_ldap_op<'a>(
|
||||
users: Vec<UserAndGroups>,
|
||||
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(
|
||||
u.user,
|
||||
&ldap_info.base_dn_str,
|
||||
attributes,
|
||||
u.groups.as_deref(),
|
||||
&ldap_info.ignored_user_attributes,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
@ -1,12 +1,29 @@
|
||||
use itertools::Itertools;
|
||||
use ldap3_proto::LdapResultCode;
|
||||
use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode};
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::domain::{
|
||||
handler::SubStringFilter,
|
||||
ldap::error::{LdapError, LdapResult},
|
||||
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)>
|
||||
where
|
||||
I: Iterator<Item = String>,
|
||||
@ -141,9 +158,9 @@ pub fn map_user_field(field: &str) -> Option<UserColumn> {
|
||||
"uid" | "user_id" | "id" => UserColumn::UserId,
|
||||
"mail" | "email" => UserColumn::Email,
|
||||
"cn" | "displayname" | "display_name" => UserColumn::DisplayName,
|
||||
"givenname" | "first_name" => UserColumn::FirstName,
|
||||
"sn" | "last_name" => UserColumn::LastName,
|
||||
"avatar" => UserColumn::Avatar,
|
||||
"givenname" | "first_name" | "firstname" => UserColumn::FirstName,
|
||||
"sn" | "last_name" | "lastname" => UserColumn::LastName,
|
||||
"avatar" | "jpegphoto" => UserColumn::Avatar,
|
||||
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
|
||||
UserColumn::CreationDate
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub group_id: GroupId,
|
||||
pub display_name: String,
|
||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
||||
pub creation_date: chrono::NaiveDateTime,
|
||||
pub uuid: Uuid,
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub refresh_token_hash: i64,
|
||||
pub user_id: UserId,
|
||||
pub expiry_date: chrono::DateTime<chrono::Utc>,
|
||||
pub expiry_date: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
@ -11,7 +11,7 @@ pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub jwt_hash: i64,
|
||||
pub user_id: UserId,
|
||||
pub expiry_date: chrono::DateTime<chrono::Utc>,
|
||||
pub expiry_date: chrono::NaiveDateTime,
|
||||
pub blacklisted: bool,
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub token: String,
|
||||
pub user_id: UserId,
|
||||
pub expiry_date: chrono::DateTime<chrono::Utc>,
|
||||
pub expiry_date: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
@ -1,6 +1,6 @@
|
||||
//! `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 crate::domain::types::{JpegPhoto, UserId, Uuid};
|
||||
@ -18,7 +18,7 @@ pub struct Model {
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar: Option<JpegPhoto>,
|
||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
||||
pub creation_date: chrono::NaiveDateTime,
|
||||
pub password_hash: Option<Vec<u8>>,
|
||||
pub totp_secret: Option<String>,
|
||||
pub mfa_type: Option<String>,
|
||||
@ -56,9 +56,9 @@ impl ColumnTrait for Column {
|
||||
Column::DisplayName => ColumnType::String(Some(255)),
|
||||
Column::FirstName => 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::PasswordHash => ColumnType::Binary,
|
||||
Column::PasswordHash => ColumnType::Binary(BlobSize::Medium),
|
||||
Column::TotpSecret => ColumnType::String(Some(64)),
|
||||
Column::MfaType => ColumnType::String(Some(64)),
|
||||
Column::Uuid => ColumnType::String(Some(36)),
|
||||
|
@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
pub use lldap_auth::{login, registration};
|
||||
|
||||
#[async_trait]
|
||||
pub trait OpaqueHandler: Clone + Send {
|
||||
pub trait OpaqueHandler: Send + Sync {
|
||||
async fn login_start(
|
||||
&self,
|
||||
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 async_trait::async_trait;
|
||||
|
||||
@ -23,7 +23,8 @@ pub mod tests {
|
||||
use crate::{
|
||||
domain::{
|
||||
handler::{
|
||||
CreateUserRequest, GroupBackendHandler, UserBackendHandler, UserRequestFilter,
|
||||
CreateUserRequest, GroupBackendHandler, UserBackendHandler,
|
||||
UserListerBackendHandler, UserRequestFilter,
|
||||
},
|
||||
sql_tables::init_table,
|
||||
types::{GroupId, UserId},
|
||||
|
@ -1,20 +1,23 @@
|
||||
use crate::domain::{
|
||||
error::{DomainError, Result},
|
||||
handler::{GroupBackendHandler, GroupRequestFilter, UpdateGroupRequest},
|
||||
handler::{
|
||||
GroupBackendHandler, GroupListerBackendHandler, GroupRequestFilter, UpdateGroupRequest,
|
||||
},
|
||||
model::{self, GroupColumn, MembershipColumn},
|
||||
sql_backend_handler::SqlBackendHandler,
|
||||
types::{Group, GroupDetails, GroupId, Uuid},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use sea_orm::{
|
||||
sea_query::{Alias, Cond, Expr, Func, IntoCondition, SimpleExpr},
|
||||
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||
QueryTrait,
|
||||
};
|
||||
use sea_query::{Cond, IntoCondition, SimpleExpr};
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
|
||||
use GroupRequestFilter::*;
|
||||
let group_table = Alias::new("groups");
|
||||
match filter {
|
||||
And(fs) => {
|
||||
if fs.is_empty() {
|
||||
@ -46,11 +49,17 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
|
||||
.into_query(),
|
||||
)
|
||||
.into_condition(),
|
||||
DisplayNameSubString(filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col((
|
||||
group_table,
|
||||
GroupColumn::DisplayName,
|
||||
))))
|
||||
.like(filter.to_sql_filter())
|
||||
.into_condition(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupBackendHandler for SqlBackendHandler {
|
||||
impl GroupListerBackendHandler for SqlBackendHandler {
|
||||
#[instrument(skip_all, level = "debug", ret, err)]
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
||||
debug!(?filters);
|
||||
@ -87,7 +96,10 @@ impl GroupBackendHandler for SqlBackendHandler {
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupBackendHandler for SqlBackendHandler {
|
||||
#[instrument(skip_all, level = "debug", ret, err)]
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails> {
|
||||
debug!(?group_id);
|
||||
@ -116,7 +128,7 @@ impl GroupBackendHandler for SqlBackendHandler {
|
||||
#[instrument(skip_all, level = "debug", ret, err)]
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId> {
|
||||
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 new_group = model::groups::ActiveModel {
|
||||
display_name: ActiveValue::Set(group_name.to_owned()),
|
||||
@ -146,7 +158,7 @@ impl GroupBackendHandler for SqlBackendHandler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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(
|
||||
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]
|
||||
async fn test_get_group_details() {
|
||||
let fixture = TestFixture::new().await;
|
||||
|
@ -2,12 +2,16 @@ use crate::domain::{
|
||||
sql_tables::{DbConnection, SchemaVersion},
|
||||
types::{GroupId, UserId, Uuid},
|
||||
};
|
||||
use sea_orm::{ConnectionTrait, FromQueryResult, Statement};
|
||||
use sea_query::{ColumnDef, Expr, ForeignKey, ForeignKeyAction, Iden, Query, Table, Value};
|
||||
use sea_orm::{
|
||||
sea_query::{self, ColumnDef, Expr, ForeignKey, ForeignKeyAction, Query, Table, Value},
|
||||
ConnectionTrait, FromQueryResult, Iden, Statement, TransactionTrait,
|
||||
};
|
||||
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 {
|
||||
Table,
|
||||
UserId,
|
||||
@ -23,7 +27,7 @@ pub enum Users {
|
||||
Uuid,
|
||||
}
|
||||
|
||||
#[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
|
||||
pub enum Groups {
|
||||
Table,
|
||||
GroupId,
|
||||
@ -32,7 +36,7 @@ pub enum Groups {
|
||||
Uuid,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
#[derive(Iden, Clone, Copy)]
|
||||
pub enum Memberships {
|
||||
Table,
|
||||
UserId,
|
||||
@ -116,6 +120,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||
.col(
|
||||
ColumnDef::new(Groups::GroupId)
|
||||
.integer()
|
||||
.auto_increment()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
@ -169,7 +174,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||
struct ShortGroupDetails {
|
||||
group_id: GroupId,
|
||||
display_name: String,
|
||||
creation_date: chrono::DateTime<chrono::Utc>,
|
||||
creation_date: chrono::NaiveDateTime,
|
||||
}
|
||||
for result in ShortGroupDetails::find_by_statement(
|
||||
builder.build(
|
||||
@ -219,7 +224,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||
#[derive(FromQueryResult)]
|
||||
struct ShortUserDetails {
|
||||
user_id: UserId,
|
||||
creation_date: chrono::DateTime<chrono::Utc>,
|
||||
creation_date: chrono::NaiveDateTime,
|
||||
}
|
||||
for result in ShortUserDetails::find_by_statement(
|
||||
builder.build(
|
||||
@ -309,7 +314,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||
Table::create()
|
||||
.table(Metadata::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Metadata::Version).tiny_integer()),
|
||||
.col(ColumnDef::new(Metadata::Version).small_integer()),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
@ -329,12 +334,158 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn migrate_from_version(
|
||||
_pool: &DbConnection,
|
||||
version: SchemaVersion,
|
||||
async fn replace_column<I: Iden + Copy + 'static, const N: usize>(
|
||||
pool: &DbConnection,
|
||||
table_name: I,
|
||||
column_name: I,
|
||||
mut new_column: ColumnDef,
|
||||
update_values: [Statement; N],
|
||||
) -> anyhow::Result<()> {
|
||||
if version.0 > 1 {
|
||||
anyhow::bail!("DB version downgrading is not supported");
|
||||
}
|
||||
// Update the definition of a column (in a compatible way). Due to Sqlite, this is more complicated:
|
||||
// - 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(())
|
||||
}
|
||||
|
@ -7,8 +7,9 @@ use super::{
|
||||
types::UserId,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use lldap_auth::opaque;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait, FromQueryResult, QuerySelect};
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait, QuerySelect};
|
||||
use secstr::SecUtf8;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
@ -50,18 +51,14 @@ impl SqlBackendHandler {
|
||||
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
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.
|
||||
Ok(model::User::find_by_id(user_id)
|
||||
.select_only()
|
||||
.column(UserColumn::PasswordHash)
|
||||
.into_model::<OnlyPasswordHash>()
|
||||
.into_tuple::<(Option<Vec<u8>>,)>()
|
||||
.one(&self.sql_pool)
|
||||
.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)?)?;
|
||||
|
||||
Ok(login::ServerLoginStartResponse {
|
||||
server_data: base64::encode(&encrypted_state),
|
||||
server_data: base64::engine::general_purpose::STANDARD.encode(encrypted_state),
|
||||
credential_response: start_response.message,
|
||||
})
|
||||
}
|
||||
@ -146,7 +143,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
server_login,
|
||||
} = bincode::deserialize(&orion::aead::open(
|
||||
&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
|
||||
// don't need.
|
||||
@ -174,7 +171,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
};
|
||||
let encrypted_state = orion::aead::seal(&secret_key, &bincode::serialize(&server_data)?)?;
|
||||
Ok(registration::ServerRegistrationStartResponse {
|
||||
server_data: base64::encode(encrypted_state),
|
||||
server_data: base64::engine::general_purpose::STANDARD.encode(encrypted_state),
|
||||
registration_response: start_response.message,
|
||||
})
|
||||
}
|
||||
@ -187,7 +184,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
let secret_key = self.get_orion_secret_key()?;
|
||||
let registration::ServerData { username } = bincode::deserialize(&orion::aead::open(
|
||||
&secret_key,
|
||||
&base64::decode(&request.server_data)?,
|
||||
&base64::engine::general_purpose::STANDARD.decode(&request.server_data)?,
|
||||
)?)?;
|
||||
|
||||
let password_file =
|
||||
|
@ -3,16 +3,15 @@ use sea_orm::Value;
|
||||
|
||||
pub type DbConnection = sea_orm::DatabaseConnection;
|
||||
|
||||
#[derive(Copy, PartialEq, Eq, Debug, Clone)]
|
||||
pub struct SchemaVersion(pub u8);
|
||||
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
|
||||
pub struct SchemaVersion(pub i16);
|
||||
|
||||
impl sea_orm::TryGetable for SchemaVersion {
|
||||
fn try_get(
|
||||
fn try_get_by<I: sea_orm::ColIdx>(
|
||||
res: &sea_orm::QueryResult,
|
||||
pre: &str,
|
||||
col: &str,
|
||||
index: I,
|
||||
) -> 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<()> {
|
||||
let version = {
|
||||
if let Some(version) = get_schema_version(pool).await {
|
||||
@ -67,7 +68,7 @@ mod tests {
|
||||
#[derive(FromQueryResult, PartialEq, Eq, Debug)]
|
||||
struct ShortUserDetails {
|
||||
display_name: String,
|
||||
creation_date: chrono::DateTime<chrono::Utc>,
|
||||
creation_date: chrono::NaiveDateTime,
|
||||
}
|
||||
let result = ShortUserDetails::find_by_statement(raw_statement(
|
||||
r#"SELECT display_name, creation_date FROM users WHERE user_id = "bôb""#,
|
||||
@ -80,7 +81,7 @@ mod tests {
|
||||
result,
|
||||
ShortUserDetails {
|
||||
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;
|
||||
sql_pool
|
||||
.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
|
||||
.unwrap();
|
||||
sql_pool
|
||||
.execute(raw_statement(
|
||||
r#"INSERT INTO users (user_id, creation_date)
|
||||
VALUES ("bôb", "1970-01-01 00:00:00")"#,
|
||||
r#"INSERT INTO users (user_id, display_name, first_name, creation_date)
|
||||
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
|
||||
.unwrap();
|
||||
@ -132,17 +140,30 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
#[derive(FromQueryResult, PartialEq, Eq, Debug)]
|
||||
struct JustUuid {
|
||||
struct SimpleUser {
|
||||
display_name: Option<String>,
|
||||
first_name: Option<String>,
|
||||
uuid: Uuid,
|
||||
}
|
||||
assert_eq!(
|
||||
JustUuid::find_by_statement(raw_statement(r#"SELECT uuid FROM users"#))
|
||||
.all(&sql_pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![JustUuid {
|
||||
uuid: crate::uuid!("a02eaf13-48a7-30f6-a3d4-040ff7c52b04")
|
||||
}]
|
||||
SimpleUser::find_by_statement(raw_statement(
|
||||
r#"SELECT display_name, first_name, uuid FROM users ORDER BY display_name"#
|
||||
))
|
||||
.all(&sql_pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
vec![
|
||||
SimpleUser {
|
||||
display_name: None,
|
||||
first_name: None,
|
||||
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)]
|
||||
struct ShortGroupDetails {
|
||||
@ -180,7 +201,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
sql_migrations::JustSchemaVersion {
|
||||
version: SchemaVersion(1)
|
||||
version: LAST_SCHEMA_VERSION
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
use super::{
|
||||
use crate::domain::{
|
||||
error::{DomainError, Result},
|
||||
handler::{CreateUserRequest, UpdateUserRequest, UserBackendHandler, UserRequestFilter},
|
||||
handler::{
|
||||
CreateUserRequest, UpdateUserRequest, UserBackendHandler, UserListerBackendHandler,
|
||||
UserRequestFilter,
|
||||
},
|
||||
model::{self, GroupColumn, UserColumn},
|
||||
sql_backend_handler::SqlBackendHandler,
|
||||
types::{GroupDetails, GroupId, User, UserAndGroups, UserId, Uuid},
|
||||
@ -8,11 +11,10 @@ use super::{
|
||||
use async_trait::async_trait;
|
||||
use sea_orm::{
|
||||
entity::IntoActiveValue,
|
||||
sea_query::{Cond, Expr, IntoCondition, SimpleExpr},
|
||||
sea_query::{Alias, Cond, Expr, Func, IntoColumnRef, IntoCondition, SimpleExpr},
|
||||
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder,
|
||||
QuerySelect, QueryTrait, Set,
|
||||
};
|
||||
use sea_query::{Alias, IntoColumnRef};
|
||||
use std::collections::HashSet;
|
||||
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))
|
||||
.eq(group_id)
|
||||
.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>> {
|
||||
match opt_name {
|
||||
None => ActiveValue::NotSet,
|
||||
@ -64,7 +73,7 @@ fn to_value(opt_name: &Option<String>) -> ActiveValue<Option<String>> {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserBackendHandler for SqlBackendHandler {
|
||||
impl UserListerBackendHandler for SqlBackendHandler {
|
||||
#[instrument(skip_all, level = "debug", ret, err)]
|
||||
async fn list_users(
|
||||
&self,
|
||||
@ -129,7 +138,10 @@ impl UserBackendHandler for SqlBackendHandler {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserBackendHandler for SqlBackendHandler {
|
||||
#[instrument(skip_all, level = "debug", ret)]
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User> {
|
||||
debug!(?user_id);
|
||||
@ -158,7 +170,7 @@ impl UserBackendHandler for SqlBackendHandler {
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()> {
|
||||
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 new_user = model::users::ActiveModel {
|
||||
user_id: Set(request.user_id),
|
||||
@ -237,6 +249,7 @@ impl UserBackendHandler for SqlBackendHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{
|
||||
handler::SubStringFilter,
|
||||
sql_backend_handler::tests::*,
|
||||
types::{JpegPhoto, UserColumn},
|
||||
};
|
||||
@ -287,6 +300,31 @@ mod tests {
|
||||
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]
|
||||
async fn test_list_users_false_filter() {
|
||||
let fixture = TestFixture::new().await;
|
||||
|
@ -1,3 +1,5 @@
|
||||
use base64::Engine;
|
||||
use chrono::{NaiveDateTime, TimeZone};
|
||||
use sea_orm::{
|
||||
entity::IntoActiveValue,
|
||||
sea_query::{value::ValueType, ArrayType, ColumnType, Nullable, ValueTypeErr},
|
||||
@ -7,18 +9,23 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use super::model::{GroupColumn, UserColumn};
|
||||
|
||||
pub type DateTime = chrono::DateTime<chrono::Utc>;
|
||||
|
||||
#[derive(PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(try_from = "&str")]
|
||||
pub struct Uuid(String);
|
||||
|
||||
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::new_v3(
|
||||
&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(),
|
||||
)
|
||||
@ -47,8 +54,11 @@ impl std::string::ToString for Uuid {
|
||||
}
|
||||
|
||||
impl TryGetable for Uuid {
|
||||
fn try_get(res: &QueryResult, pre: &str, col: &str) -> std::result::Result<Self, TryGetError> {
|
||||
Ok(Uuid(String::try_get(res, pre, col)?))
|
||||
fn try_get_by<I: sea_orm::ColIdx>(
|
||||
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 {
|
||||
fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
|
||||
Ok(UserId::new(&String::try_get(res, pre, col)?))
|
||||
fn try_get_by<I: sea_orm::ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
|
||||
Ok(UserId::new(&String::try_get_by(res, index)?))
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,13 +225,15 @@ impl TryFrom<String> for JpegPhoto {
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(string: String) -> anyhow::Result<Self> {
|
||||
// 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 {
|
||||
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 {
|
||||
fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
|
||||
<Self as std::convert::TryFrom<Vec<_>>>::try_from(Vec::<u8>::try_get(res, pre, col)?)
|
||||
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_by(res, index)?)
|
||||
.map_err(|e| {
|
||||
TryGetError::DbErr(DbErr::TryIntoErr {
|
||||
from: "[u8]",
|
||||
@ -308,15 +320,14 @@ pub struct User {
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar: Option<JpegPhoto>,
|
||||
pub creation_date: DateTime,
|
||||
pub creation_date: NaiveDateTime,
|
||||
pub uuid: Uuid,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Default for User {
|
||||
fn default() -> Self {
|
||||
use chrono::TimeZone;
|
||||
let epoch = chrono::Utc.timestamp_opt(0, 0).unwrap();
|
||||
let epoch = chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc();
|
||||
User {
|
||||
user_id: UserId::default(),
|
||||
email: String::new(),
|
||||
@ -340,8 +351,8 @@ impl From<GroupId> for Value {
|
||||
}
|
||||
|
||||
impl TryGetable for GroupId {
|
||||
fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
|
||||
Ok(GroupId(i32::try_get(res, pre, col)?))
|
||||
fn try_get_by<I: sea_orm::ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
|
||||
Ok(GroupId(i32::try_get_by(res, index)?))
|
||||
}
|
||||
}
|
||||
|
||||
@ -359,7 +370,7 @@ impl ValueType for GroupId {
|
||||
}
|
||||
|
||||
fn column_type() -> ColumnType {
|
||||
ColumnType::Integer(None)
|
||||
ColumnType::Integer
|
||||
}
|
||||
}
|
||||
|
||||
@ -373,7 +384,7 @@ impl TryFromU64 for GroupId {
|
||||
pub struct Group {
|
||||
pub id: GroupId,
|
||||
pub display_name: String,
|
||||
pub creation_date: DateTime,
|
||||
pub creation_date: NaiveDateTime,
|
||||
pub uuid: Uuid,
|
||||
pub users: Vec<UserId>,
|
||||
}
|
||||
@ -382,7 +393,7 @@ pub struct Group {
|
||||
pub struct GroupDetails {
|
||||
pub group_id: GroupId,
|
||||
pub display_name: String,
|
||||
pub creation_date: DateTime,
|
||||
pub creation_date: NaiveDateTime,
|
||||
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},
|
||||
},
|
||||
infra::{
|
||||
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler, ValidationResults},
|
||||
tcp_backend_handler::*,
|
||||
tcp_server::{error_to_http_response, AppState, TcpError, TcpResult},
|
||||
},
|
||||
@ -87,11 +88,10 @@ async fn get_refresh<Backend>(
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let backend_handler = &data.backend_handler;
|
||||
let jwt_key = &data.jwt_key;
|
||||
let (refresh_token_hash, user) = get_refresh_token(request)?;
|
||||
let found = data
|
||||
.backend_handler
|
||||
.get_tcp_handler()
|
||||
.check_token(refresh_token_hash, &user)
|
||||
.await?;
|
||||
if !found {
|
||||
@ -99,7 +99,8 @@ where
|
||||
"Invalid refresh token".to_string(),
|
||||
)));
|
||||
}
|
||||
Ok(backend_handler
|
||||
Ok(data
|
||||
.get_readonly_handler()
|
||||
.get_user_groups(&user)
|
||||
.await
|
||||
.map(|groups| create_jwt(jwt_key, user.to_string(), groups))
|
||||
@ -145,7 +146,7 @@ where
|
||||
.get("user_id")
|
||||
.ok_or_else(|| TcpError::BadRequest("Missing user ID".to_string()))?;
|
||||
let user_results = data
|
||||
.backend_handler
|
||||
.get_readonly_handler()
|
||||
.list_users(
|
||||
Some(UserRequestFilter::Or(vec![
|
||||
UserRequestFilter::UserId(UserId::new(user_string)),
|
||||
@ -163,7 +164,7 @@ where
|
||||
}
|
||||
let user = &user_results[0].user;
|
||||
let token = match data
|
||||
.backend_handler
|
||||
.get_tcp_handler()
|
||||
.start_password_reset(&user.user_id)
|
||||
.await?
|
||||
{
|
||||
@ -214,13 +215,17 @@ where
|
||||
let token = request
|
||||
.match_info()
|
||||
.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
|
||||
.backend_handler
|
||||
.get_tcp_handler()
|
||||
.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
|
||||
.backend_handler
|
||||
.get_tcp_handler()
|
||||
.delete_password_reset_token(token)
|
||||
.await;
|
||||
let groups = HashSet::new();
|
||||
@ -262,10 +267,10 @@ where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let (refresh_token_hash, user) = get_refresh_token(request)?;
|
||||
data.backend_handler
|
||||
data.get_tcp_handler()
|
||||
.delete_refresh_token(refresh_token_hash)
|
||||
.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();
|
||||
for jwt in new_blacklisted_jwts {
|
||||
jwt_blacklist.insert(jwt);
|
||||
@ -316,7 +321,7 @@ async fn opaque_login_start<Backend>(
|
||||
where
|
||||
Backend: OpaqueHandler + 'static,
|
||||
{
|
||||
data.backend_handler
|
||||
data.get_opaque_handler()
|
||||
.login_start(request.into_inner())
|
||||
.await
|
||||
.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
|
||||
// token.
|
||||
let groups = data.backend_handler.get_user_groups(name).await?;
|
||||
let (refresh_token, max_age) = data.backend_handler.create_refresh_token(name).await?;
|
||||
let groups = data.get_readonly_handler().get_user_groups(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 refresh_token_plus_name = refresh_token + "+" + name.as_str();
|
||||
|
||||
@ -370,7 +375,7 @@ where
|
||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
let name = data
|
||||
.backend_handler
|
||||
.get_opaque_handler()
|
||||
.login_finish(request.into_inner())
|
||||
.await?;
|
||||
get_login_successful_response(&data, &name).await
|
||||
@ -401,7 +406,7 @@ where
|
||||
name: user_id.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
|
||||
}
|
||||
|
||||
@ -427,7 +432,7 @@ where
|
||||
{
|
||||
let name = request.name.clone();
|
||||
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
|
||||
}
|
||||
|
||||
@ -446,14 +451,15 @@ where
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn opaque_register_start<Backend>(
|
||||
request: actix_web::HttpRequest,
|
||||
mut payload: actix_web::web::Payload,
|
||||
payload: actix_web::web::Payload,
|
||||
data: web::Data<AppState<Backend>>,
|
||||
) -> TcpResult<registration::ServerRegistrationStartResponse>
|
||||
where
|
||||
Backend: BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
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
|
||||
.ok()
|
||||
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
|
||||
@ -463,14 +469,14 @@ where
|
||||
let registration_start_request =
|
||||
web::Json::<registration::ClientRegistrationStartRequest>::from_request(
|
||||
&request,
|
||||
&mut payload.0,
|
||||
inner_payload,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| TcpError::BadRequest(format!("{:#?}", e)))?
|
||||
.into_inner();
|
||||
let user_id = UserId::new(®istration_start_request.username);
|
||||
let user_is_admin = data
|
||||
.backend_handler
|
||||
.get_readonly_handler()
|
||||
.get_user_groups(&user_id)
|
||||
.await?
|
||||
.iter()
|
||||
@ -481,7 +487,7 @@ where
|
||||
));
|
||||
}
|
||||
Ok(data
|
||||
.backend_handler
|
||||
.get_opaque_handler()
|
||||
.registration_start(registration_start_request)
|
||||
.await?)
|
||||
}
|
||||
@ -508,7 +514,7 @@ async fn opaque_register_finish<Backend>(
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
data.backend_handler
|
||||
data.get_opaque_handler()
|
||||
.registration_finish(request.into_inner())
|
||||
.await?;
|
||||
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)]
|
||||
pub(crate) fn check_if_token_is_valid<Backend>(
|
||||
pub(crate) fn check_if_token_is_valid<Backend: BackendHandler>(
|
||||
state: &AppState<Backend>,
|
||||
token_str: &str,
|
||||
) -> 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) {
|
||||
return Err(ErrorUnauthorized("JWT was logged out"));
|
||||
}
|
||||
let is_in_group = |name| token.claims().groups.contains(name);
|
||||
Ok(ValidationResults {
|
||||
user: UserId::new(&token.claims().user),
|
||||
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
|
||||
},
|
||||
})
|
||||
Ok(state.backend_handler.get_permissions_from_groups(
|
||||
UserId::new(&token.claims().user),
|
||||
token.claims().groups.iter(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig)
|
||||
pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig, enable_password_reset: bool)
|
||||
where
|
||||
Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static,
|
||||
{
|
||||
@ -694,14 +635,6 @@ where
|
||||
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("/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::scope("/opaque/register")
|
||||
@ -715,4 +648,14 @@ where
|
||||
.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 serde::{Deserialize, Serialize};
|
||||
|
||||
@ -26,6 +26,9 @@ pub enum Command {
|
||||
/// Send a test email.
|
||||
#[clap(name = "send_test_email")]
|
||||
SendTestEmail(TestEmailOpts),
|
||||
/// Create database schema.
|
||||
#[clap(name = "create_schema")]
|
||||
CreateSchema(RunOpts),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
@ -74,6 +77,10 @@ pub struct RunOpts {
|
||||
#[clap(long, env = "LLDAP_HTTP_URL")]
|
||||
pub http_url: Option<String>,
|
||||
|
||||
/// Database connection URL
|
||||
#[clap(short, long, env = "LLDAP_DATABASE_URL")]
|
||||
pub database_url: Option<String>,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub smtp_opts: SmtpOpts,
|
||||
|
||||
@ -95,7 +102,7 @@ pub struct TestEmailOpts {
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
#[clap(next_help_heading = Some("LDAPS"), setting = clap::AppSettings::DeriveDisplayOrder)]
|
||||
#[clap(next_help_heading = Some("LDAPS"))]
|
||||
pub struct LdapsOpts {
|
||||
/// Enable LDAPS. Default: false.
|
||||
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__ENABLED")]
|
||||
@ -114,16 +121,16 @@ pub struct LdapsOpts {
|
||||
pub ldaps_key_file: Option<String>,
|
||||
}
|
||||
|
||||
clap::arg_enum! {
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, clap::ValueEnum)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum SmtpEncryption {
|
||||
TLS,
|
||||
STARTTLS,
|
||||
}
|
||||
None,
|
||||
Tls,
|
||||
StartTls,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
#[clap(next_help_heading = Some("SMTP"), setting = clap::AppSettings::DeriveDisplayOrder)]
|
||||
#[clap(next_help_heading = Some("SMTP"))]
|
||||
pub struct SmtpOpts {
|
||||
/// Sender email address.
|
||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")]
|
||||
@ -150,10 +157,10 @@ pub struct SmtpOpts {
|
||||
pub smtp_password: Option<String>,
|
||||
|
||||
/// 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>,
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
|
@ -29,7 +29,7 @@ pub struct MailOptions {
|
||||
pub user: String,
|
||||
#[builder(default = r#"SecUtf8::from("")"#)]
|
||||
pub password: SecUtf8,
|
||||
#[builder(default = "SmtpEncryption::TLS")]
|
||||
#[builder(default = "SmtpEncryption::Tls")]
|
||||
pub smtp_encryption: SmtpEncryption,
|
||||
/// Deprecated.
|
||||
#[builder(default = "None")]
|
||||
@ -209,6 +209,10 @@ impl ConfigOverrider for RunOpts {
|
||||
if let Some(url) = self.http_url.as_ref() {
|
||||
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.ldaps_opts.override_config(config);
|
||||
}
|
||||
@ -266,6 +270,9 @@ impl ConfigOverrider for SmtpOpts {
|
||||
if let Some(password) = &self.smtp_password {
|
||||
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 {
|
||||
config.smtp_options.tls_required = Some(tls_required);
|
||||
}
|
||||
|
@ -1,29 +1,84 @@
|
||||
use crate::{
|
||||
domain::handler::BackendHandler,
|
||||
domain::{handler::BackendHandler, types::UserId},
|
||||
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,
|
||||
graphql::{mutation::Mutation, query::Query},
|
||||
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 juniper::{EmptySubscription, RootNode};
|
||||
use juniper_actix::{graphiql_handler, graphql_handler, playground_handler};
|
||||
|
||||
use super::{mutation::Mutation, query::Query};
|
||||
use juniper::{
|
||||
http::{
|
||||
graphiql::graphiql_source, playground::playground_source, GraphQLBatchRequest,
|
||||
GraphQLRequest,
|
||||
},
|
||||
EmptySubscription, FieldError, RootNode, ScalarValue,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
pub struct Context<Handler: BackendHandler> {
|
||||
pub handler: Box<Handler>,
|
||||
pub handler: AccessControlledBackendHandler<Handler>,
|
||||
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> {}
|
||||
|
||||
type Schema<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(
|
||||
Query::<Handler>::new(),
|
||||
Mutation::<Handler>::new(),
|
||||
@ -52,30 +107,134 @@ pub fn export_schema(opts: ExportGraphQLSchemaOpts) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
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> {
|
||||
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,
|
||||
mut payload: actix_web::web::Payload,
|
||||
payload: actix_web::web::Payload,
|
||||
data: web::Data<AppState<Handler>>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
use actix_web::FromRequest;
|
||||
let bearer = BearerAuth::from_request(&req, &mut payload.0).await?;
|
||||
let mut inner_payload = payload.into_inner();
|
||||
let bearer = BearerAuth::from_request(&req, &mut inner_payload).await?;
|
||||
let validation_result = check_if_token_is_valid(&data, bearer.token())?;
|
||||
let context = Context::<Handler> {
|
||||
handler: Box::new(data.backend_handler.clone()),
|
||||
handler: data.backend_handler.clone(),
|
||||
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)
|
||||
where
|
||||
Backend: BackendHandler + Sync + 'static,
|
||||
Backend: BackendHandler + Clone + 'static,
|
||||
{
|
||||
let json_config = web::JsonConfig::default()
|
||||
.limit(4096)
|
||||
|
@ -1,8 +1,18 @@
|
||||
use crate::domain::{
|
||||
handler::{BackendHandler, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest},
|
||||
types::{GroupId, JpegPhoto, UserId},
|
||||
use crate::{
|
||||
domain::{
|
||||
handler::{BackendHandler, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest},
|
||||
types::{GroupId, JpegPhoto, UserId},
|
||||
},
|
||||
infra::{
|
||||
access_control::{
|
||||
AdminBackendHandler, ReadonlyBackendHandler, UserReadableBackendHandler,
|
||||
UserWriteableBackendHandler,
|
||||
},
|
||||
graphql::api::field_error_callback,
|
||||
},
|
||||
};
|
||||
use anyhow::Context as AnyhowContext;
|
||||
use base64::Engine;
|
||||
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
|
||||
use tracing::{debug, debug_span, Instrument};
|
||||
|
||||
@ -65,30 +75,28 @@ impl Success {
|
||||
}
|
||||
|
||||
#[graphql_object(context = Context<Handler>)]
|
||||
impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
impl<Handler: BackendHandler> Mutation<Handler> {
|
||||
async fn create_user(
|
||||
context: &Context<Handler>,
|
||||
user: CreateUserInput,
|
||||
) -> FieldResult<super::query::User<Handler>> {
|
||||
let span = debug_span!("[GraphQL mutation] create_user");
|
||||
span.in_scope(|| {
|
||||
debug!(?user.id);
|
||||
debug!("{:?}", &user.id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized user creation".into());
|
||||
}
|
||||
let handler = context
|
||||
.get_admin_handler()
|
||||
.ok_or_else(field_error_callback(&span, "Unauthorized user creation"))?;
|
||||
let user_id = UserId::new(&user.id);
|
||||
let avatar = user
|
||||
.avatar
|
||||
.map(base64::decode)
|
||||
.map(|bytes| base64::engine::general_purpose::STANDARD.decode(bytes))
|
||||
.transpose()
|
||||
.context("Invalid base64 image")?
|
||||
.map(JpegPhoto::try_from)
|
||||
.transpose()
|
||||
.context("Provided image is not a valid JPEG")?;
|
||||
context
|
||||
.handler
|
||||
handler
|
||||
.create_user(CreateUserRequest {
|
||||
user_id: user_id.clone(),
|
||||
email: user.email,
|
||||
@ -99,8 +107,7 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
})
|
||||
.instrument(span.clone())
|
||||
.await?;
|
||||
Ok(context
|
||||
.handler
|
||||
Ok(handler
|
||||
.get_user_details(&user_id)
|
||||
.instrument(span)
|
||||
.await
|
||||
@ -115,13 +122,11 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
span.in_scope(|| {
|
||||
debug!(?name);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group creation".into());
|
||||
}
|
||||
let group_id = context.handler.create_group(&name).await?;
|
||||
Ok(context
|
||||
.handler
|
||||
let handler = context
|
||||
.get_admin_handler()
|
||||
.ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?;
|
||||
let group_id = handler.create_group(&name).await?;
|
||||
Ok(handler
|
||||
.get_group_details(group_id)
|
||||
.instrument(span)
|
||||
.await
|
||||
@ -137,20 +142,18 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
debug!(?user.id);
|
||||
});
|
||||
let user_id = UserId::new(&user.id);
|
||||
if !context.validation_result.can_write(&user_id) {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized user update".into());
|
||||
}
|
||||
let handler = context
|
||||
.get_writeable_handler(&user_id)
|
||||
.ok_or_else(field_error_callback(&span, "Unauthorized user update"))?;
|
||||
let avatar = user
|
||||
.avatar
|
||||
.map(base64::decode)
|
||||
.map(|bytes| base64::engine::general_purpose::STANDARD.decode(bytes))
|
||||
.transpose()
|
||||
.context("Invalid base64 image")?
|
||||
.map(JpegPhoto::try_from)
|
||||
.transpose()
|
||||
.context("Provided image is not a valid JPEG")?;
|
||||
context
|
||||
.handler
|
||||
handler
|
||||
.update_user(UpdateUserRequest {
|
||||
user_id,
|
||||
email: user.email,
|
||||
@ -172,16 +175,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
span.in_scope(|| {
|
||||
debug!(?group.id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group update".into());
|
||||
}
|
||||
let handler = context
|
||||
.get_admin_handler()
|
||||
.ok_or_else(field_error_callback(&span, "Unauthorized group update"))?;
|
||||
if group.id == 1 {
|
||||
span.in_scope(|| debug!("Cannot change admin group details"));
|
||||
return Err("Cannot change admin group details".into());
|
||||
}
|
||||
context
|
||||
.handler
|
||||
handler
|
||||
.update_group(UpdateGroupRequest {
|
||||
group_id: GroupId(group.id),
|
||||
display_name: group.display_name,
|
||||
@ -200,12 +201,13 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
span.in_scope(|| {
|
||||
debug!(?user_id, ?group_id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group membership modification".into());
|
||||
}
|
||||
context
|
||||
.handler
|
||||
let handler = context
|
||||
.get_admin_handler()
|
||||
.ok_or_else(field_error_callback(
|
||||
&span,
|
||||
"Unauthorized group membership modification",
|
||||
))?;
|
||||
handler
|
||||
.add_user_to_group(&UserId::new(&user_id), GroupId(group_id))
|
||||
.instrument(span)
|
||||
.await?;
|
||||
@ -221,17 +223,18 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
span.in_scope(|| {
|
||||
debug!(?user_id, ?group_id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group membership modification".into());
|
||||
}
|
||||
let handler = context
|
||||
.get_admin_handler()
|
||||
.ok_or_else(field_error_callback(
|
||||
&span,
|
||||
"Unauthorized group membership modification",
|
||||
))?;
|
||||
let user_id = UserId::new(&user_id);
|
||||
if context.validation_result.user == user_id && group_id == 1 {
|
||||
span.in_scope(|| debug!("Cannot remove admin rights for current user"));
|
||||
return Err("Cannot remove admin rights for current user".into());
|
||||
}
|
||||
context
|
||||
.handler
|
||||
handler
|
||||
.remove_user_from_group(&user_id, GroupId(group_id))
|
||||
.instrument(span)
|
||||
.await?;
|
||||
@ -244,19 +247,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
debug!(?user_id);
|
||||
});
|
||||
let user_id = UserId::new(&user_id);
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized user deletion".into());
|
||||
}
|
||||
let handler = context
|
||||
.get_admin_handler()
|
||||
.ok_or_else(field_error_callback(&span, "Unauthorized user deletion"))?;
|
||||
if context.validation_result.user == user_id {
|
||||
span.in_scope(|| debug!("Cannot delete current user"));
|
||||
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())
|
||||
}
|
||||
|
||||
@ -265,16 +263,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
span.in_scope(|| {
|
||||
debug!(?group_id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group deletion".into());
|
||||
}
|
||||
let handler = context
|
||||
.get_admin_handler()
|
||||
.ok_or_else(field_error_callback(&span, "Unauthorized group deletion"))?;
|
||||
if group_id == 1 {
|
||||
span.in_scope(|| debug!("Cannot delete admin group"));
|
||||
return Err("Cannot delete admin group".into());
|
||||
}
|
||||
context
|
||||
.handler
|
||||
handler
|
||||
.delete_group(GroupId(group_id))
|
||||
.instrument(span)
|
||||
.await?;
|
||||
|
@ -1,8 +1,15 @@
|
||||
use crate::domain::{
|
||||
handler::BackendHandler,
|
||||
ldap::utils::map_user_field,
|
||||
types::{GroupDetails, GroupId, UserColumn, UserId},
|
||||
use crate::{
|
||||
domain::{
|
||||
handler::BackendHandler,
|
||||
ldap::utils::map_user_field,
|
||||
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 serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, debug_span, Instrument};
|
||||
@ -111,7 +118,7 @@ impl<Handler: BackendHandler> Query<Handler> {
|
||||
}
|
||||
|
||||
#[graphql_object(context = Context<Handler>)]
|
||||
impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||
impl<Handler: BackendHandler> Query<Handler> {
|
||||
fn api_version() -> &'static str {
|
||||
"1.0"
|
||||
}
|
||||
@ -122,12 +129,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||
debug!(?user_id);
|
||||
});
|
||||
let user_id = UserId::new(&user_id);
|
||||
if !context.validation_result.can_read(&user_id) {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to user data".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
let handler = context
|
||||
.get_readable_handler(&user_id)
|
||||
.ok_or_else(field_error_callback(
|
||||
&span,
|
||||
"Unauthorized access to user data",
|
||||
))?;
|
||||
Ok(handler
|
||||
.get_user_details(&user_id)
|
||||
.instrument(span)
|
||||
.await
|
||||
@ -142,12 +150,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||
span.in_scope(|| {
|
||||
debug!(?filters);
|
||||
});
|
||||
if !context.validation_result.is_admin_or_readonly() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to user list".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
let handler = context
|
||||
.get_readonly_handler()
|
||||
.ok_or_else(field_error_callback(
|
||||
&span,
|
||||
"Unauthorized access to user list",
|
||||
))?;
|
||||
Ok(handler
|
||||
.list_users(filters.map(TryInto::try_into).transpose()?, false)
|
||||
.instrument(span)
|
||||
.await
|
||||
@ -156,12 +165,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||
|
||||
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
|
||||
let span = debug_span!("[GraphQL query] groups");
|
||||
if !context.validation_result.is_admin_or_readonly() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to group list".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
let handler = context
|
||||
.get_readonly_handler()
|
||||
.ok_or_else(field_error_callback(
|
||||
&span,
|
||||
"Unauthorized access to group list",
|
||||
))?;
|
||||
Ok(handler
|
||||
.list_groups(None)
|
||||
.instrument(span)
|
||||
.await
|
||||
@ -173,12 +183,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||
span.in_scope(|| {
|
||||
debug!(?group_id);
|
||||
});
|
||||
if !context.validation_result.is_admin_or_readonly() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to group data".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
let handler = context
|
||||
.get_readonly_handler()
|
||||
.ok_or_else(field_error_callback(
|
||||
&span,
|
||||
"Unauthorized access to group data",
|
||||
))?;
|
||||
Ok(handler
|
||||
.get_group_details(GroupId(group_id))
|
||||
.instrument(span)
|
||||
.await
|
||||
@ -204,7 +215,7 @@ impl<Handler: BackendHandler> Default for User<Handler> {
|
||||
}
|
||||
|
||||
#[graphql_object(context = Context<Handler>)]
|
||||
impl<Handler: BackendHandler + Sync> User<Handler> {
|
||||
impl<Handler: BackendHandler> User<Handler> {
|
||||
fn id(&self) -> &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> {
|
||||
self.user.creation_date
|
||||
chrono::Utc.from_utc_datetime(&self.user.creation_date)
|
||||
}
|
||||
|
||||
fn uuid(&self) -> &str {
|
||||
@ -243,8 +254,10 @@ impl<Handler: BackendHandler + Sync> User<Handler> {
|
||||
span.in_scope(|| {
|
||||
debug!(user_id = ?self.user.user_id);
|
||||
});
|
||||
Ok(context
|
||||
.handler
|
||||
let handler = context
|
||||
.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)
|
||||
.instrument(span)
|
||||
.await
|
||||
@ -275,14 +288,14 @@ impl<Handler: BackendHandler> From<DomainUserAndGroups> for User<Handler> {
|
||||
pub struct Group<Handler: BackendHandler> {
|
||||
group_id: i32,
|
||||
display_name: String,
|
||||
creation_date: chrono::DateTime<chrono::Utc>,
|
||||
creation_date: chrono::NaiveDateTime,
|
||||
uuid: String,
|
||||
members: Option<Vec<String>>,
|
||||
_phantom: std::marker::PhantomData<Box<Handler>>,
|
||||
}
|
||||
|
||||
#[graphql_object(context = Context<Handler>)]
|
||||
impl<Handler: BackendHandler + Sync> Group<Handler> {
|
||||
impl<Handler: BackendHandler> Group<Handler> {
|
||||
fn id(&self) -> i32 {
|
||||
self.group_id
|
||||
}
|
||||
@ -290,7 +303,7 @@ impl<Handler: BackendHandler + Sync> Group<Handler> {
|
||||
self.display_name.clone()
|
||||
}
|
||||
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
|
||||
self.creation_date
|
||||
chrono::Utc.from_utc_datetime(&self.creation_date)
|
||||
}
|
||||
fn uuid(&self) -> String {
|
||||
self.uuid.clone()
|
||||
@ -301,12 +314,13 @@ impl<Handler: BackendHandler + Sync> Group<Handler> {
|
||||
span.in_scope(|| {
|
||||
debug!(name = %self.display_name);
|
||||
});
|
||||
if !context.validation_result.is_admin_or_readonly() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to group data".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
let handler = context
|
||||
.get_readonly_handler()
|
||||
.ok_or_else(field_error_callback(
|
||||
&span,
|
||||
"Unauthorized access to group data",
|
||||
))?;
|
||||
Ok(handler
|
||||
.list_users(
|
||||
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
|
||||
false,
|
||||
@ -346,7 +360,9 @@ impl<Handler: BackendHandler> From<DomainGroup> for Group<Handler> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{domain::handler::MockTestBackendHandler, infra::auth_service::ValidationResults};
|
||||
use crate::{
|
||||
domain::handler::MockTestBackendHandler, infra::access_control::ValidationResults,
|
||||
};
|
||||
use chrono::TimeZone;
|
||||
use juniper::{
|
||||
execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType,
|
||||
@ -389,7 +405,7 @@ mod tests {
|
||||
Ok(DomainUser {
|
||||
user_id: UserId::new("bob"),
|
||||
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"),
|
||||
..Default::default()
|
||||
})
|
||||
@ -398,17 +414,15 @@ mod tests {
|
||||
groups.insert(GroupDetails {
|
||||
group_id: GroupId(3),
|
||||
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"),
|
||||
});
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.return_once(|_| Ok(groups));
|
||||
|
||||
let context = Context::<MockTestBackendHandler> {
|
||||
handler: Box::new(mock),
|
||||
validation_result: ValidationResults::admin(),
|
||||
};
|
||||
let context =
|
||||
Context::<MockTestBackendHandler>::new_for_tests(mock, ValidationResults::admin());
|
||||
|
||||
let schema = schema(Query::<MockTestBackendHandler>::new());
|
||||
assert_eq!(
|
||||
@ -485,10 +499,8 @@ mod tests {
|
||||
])
|
||||
});
|
||||
|
||||
let context = Context::<MockTestBackendHandler> {
|
||||
handler: Box::new(mock),
|
||||
validation_result: ValidationResults::admin(),
|
||||
};
|
||||
let context =
|
||||
Context::<MockTestBackendHandler>::new_for_tests(mock, ValidationResults::admin());
|
||||
|
||||
let schema = schema(Query::<MockTestBackendHandler>::new());
|
||||
assert_eq!(
|
||||
|
@ -99,6 +99,7 @@ fn get_tls_connector() -> Result<RustlsTlsConnector> {
|
||||
#[instrument(skip_all, level = "info", err)]
|
||||
pub async fn check_ldaps(ldaps_options: &LdapsOptions) -> Result<()> {
|
||||
if !ldaps_options.enabled {
|
||||
info!("LDAPS not enabled");
|
||||
return Ok(());
|
||||
};
|
||||
let tls_connector = get_tls_connector()?;
|
||||
|
@ -1,5 +1,7 @@
|
||||
use sea_orm::ConnectionTrait;
|
||||
use sea_query::{ColumnDef, ForeignKey, ForeignKeyAction, Iden, Table};
|
||||
use sea_orm::{
|
||||
sea_query::{self, ColumnDef, ForeignKey, ForeignKeyAction, Iden, Table},
|
||||
ConnectionTrait,
|
||||
};
|
||||
|
||||
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},
|
||||
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_server::ServerBuilder;
|
||||
@ -64,7 +67,7 @@ async fn handle_ldap_stream<Stream, Backend>(
|
||||
) -> Result<Stream>
|
||||
where
|
||||
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;
|
||||
let (r, w) = tokio::io::split(stream);
|
||||
@ -73,7 +76,7 @@ where
|
||||
let mut resp = FramedWrite::new(w, LdapCodec);
|
||||
|
||||
let mut session = LdapHandler::new(
|
||||
backend_handler,
|
||||
AccessControlledBackendHandler::new(backend_handler),
|
||||
ldap_base_dn,
|
||||
ignored_user_attributes,
|
||||
ignored_group_attributes,
|
||||
@ -145,7 +148,7 @@ pub fn build_ldap_server<Backend>(
|
||||
server_builder: ServerBuilder,
|
||||
) -> Result<ServerBuilder>
|
||||
where
|
||||
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
|
||||
Backend: BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
|
||||
{
|
||||
let context = (
|
||||
backend_handler,
|
||||
|
@ -21,17 +21,29 @@ async fn send_email(to: Mailbox, subject: &str, body: String, options: &MailOpti
|
||||
.reply_to(reply_to)
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.body(body)?;
|
||||
let creds = Credentials::new(
|
||||
options.user.clone(),
|
||||
options.password.unsecure().to_string(),
|
||||
);
|
||||
let relay_factory = match options.smtp_encryption {
|
||||
SmtpEncryption::TLS => AsyncSmtpTransport::<Tokio1Executor>::relay,
|
||||
SmtpEncryption::STARTTLS => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay,
|
||||
.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)?
|
||||
}
|
||||
};
|
||||
let mailer = relay_factory(&options.server)?.credentials(creds).build();
|
||||
mailer.send(email).await?;
|
||||
if options.user.as_str() != "" {
|
||||
let creds = Credentials::new(
|
||||
options.user.clone(),
|
||||
options.password.unsecure().to_string(),
|
||||
);
|
||||
mailer = mailer.credentials(creds)
|
||||
}
|
||||
|
||||
mailer.port(options.port).build().send(email).await?;
|
||||
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/{}
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
pub mod access_control;
|
||||
pub mod auth_service;
|
||||
pub mod cli;
|
||||
pub mod configuration;
|
||||
|
@ -7,10 +7,9 @@ use crate::domain::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use sea_orm::{
|
||||
sea_query::Cond, ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, IntoActiveModel,
|
||||
QueryFilter, QuerySelect,
|
||||
sea_query::{Cond, Expr},
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QuerySelect,
|
||||
};
|
||||
use sea_query::Expr;
|
||||
use std::collections::HashSet;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
@ -24,11 +23,6 @@ fn gen_random_string(len: usize) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct OnlyJwtHash {
|
||||
jwt_hash: i64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TcpBackendHandler for SqlBackendHandler {
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
@ -37,11 +31,11 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
.select_only()
|
||||
.column(JwtStorageColumn::JwtHash)
|
||||
.filter(JwtStorageColumn::Blacklisted.eq(true))
|
||||
.into_model::<OnlyJwtHash>()
|
||||
.into_tuple::<(i64,)>()
|
||||
.all(&self.sql_pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| m.jwt_hash as u64)
|
||||
.map(|m| m.0 as u64)
|
||||
.collect::<HashSet<u64>>())
|
||||
}
|
||||
|
||||
@ -61,7 +55,7 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
let new_token = model::jwt_refresh_storage::Model {
|
||||
refresh_token_hash: refresh_token_hash as i64,
|
||||
user_id: user.clone(),
|
||||
expiry_date: chrono::Utc::now() + duration,
|
||||
expiry_date: chrono::Utc::now().naive_utc() + duration,
|
||||
}
|
||||
.into_active_model();
|
||||
new_token.insert(&self.sql_pool).await?;
|
||||
@ -91,11 +85,11 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
.add(JwtStorageColumn::UserId.eq(user))
|
||||
.add(JwtStorageColumn::Blacklisted.eq(false)),
|
||||
)
|
||||
.into_model::<OnlyJwtHash>()
|
||||
.into_tuple::<(i64,)>()
|
||||
.all(&self.sql_pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|t| t.jwt_hash as u64)
|
||||
.map(|t| t.0 as u64)
|
||||
.collect::<HashSet<u64>>();
|
||||
model::JwtStorage::update_many()
|
||||
.col_expr(JwtStorageColumn::Blacklisted, Expr::value(true))
|
||||
@ -131,7 +125,7 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
let new_token = model::password_reset_tokens::Model {
|
||||
token: token.clone(),
|
||||
user_id: user.clone(),
|
||||
expiry_date: chrono::Utc::now() + duration,
|
||||
expiry_date: chrono::Utc::now().naive_utc() + duration,
|
||||
}
|
||||
.into_active_model();
|
||||
new_token.insert(&self.sql_pool).await?;
|
||||
|
@ -4,7 +4,7 @@ use std::collections::HashSet;
|
||||
use crate::domain::{error::Result, types::UserId};
|
||||
|
||||
#[async_trait]
|
||||
pub trait TcpBackendHandler {
|
||||
pub trait TcpBackendHandler: Sync {
|
||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
||||
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>;
|
||||
@ -20,38 +20,3 @@ pub trait TcpBackendHandler {
|
||||
|
||||
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,
|
||||
},
|
||||
infra::{
|
||||
access_control::{AccessControlledBackendHandler, ReadonlyBackendHandler},
|
||||
auth_service,
|
||||
configuration::{Configuration, MailOptions},
|
||||
logging::CustomRootSpanBuilder,
|
||||
@ -12,12 +13,12 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use actix_files::{Files, NamedFile};
|
||||
use actix_http::HttpServiceBuilder;
|
||||
use actix_http::{header, HttpServiceBuilder};
|
||||
use actix_server::ServerBuilder;
|
||||
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 hmac::{Hmac, NewMac};
|
||||
use hmac::Hmac;
|
||||
use sha2::Sha512;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
@ -37,6 +38,8 @@ pub enum TcpError {
|
||||
BadRequest(String),
|
||||
#[error("Internal server error: `{0}`")]
|
||||
InternalServerError(String),
|
||||
#[error("Not found: `{0}`")]
|
||||
NotFoundError(String),
|
||||
#[error("Unauthorized: `{0}`")]
|
||||
UnauthorizedError(String),
|
||||
}
|
||||
@ -57,12 +60,23 @@ pub(crate) fn error_to_http_response(error: TcpError) -> HttpResponse {
|
||||
| DomainError::EntityNotFound(_) => HttpResponse::BadRequest(),
|
||||
},
|
||||
TcpError::BadRequest(_) => HttpResponse::BadRequest(),
|
||||
TcpError::NotFoundError(_) => HttpResponse::NotFound(),
|
||||
TcpError::InternalServerError(_) => HttpResponse::InternalServerError(),
|
||||
TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(),
|
||||
}
|
||||
.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>(
|
||||
cfg: &mut web::ServiceConfig,
|
||||
backend_handler: Backend,
|
||||
@ -71,23 +85,37 @@ fn http_config<Backend>(
|
||||
server_url: String,
|
||||
mail_options: MailOptions,
|
||||
) 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> {
|
||||
backend_handler,
|
||||
jwt_key: Hmac::new_varkey(jwt_secret.unsecure().as_bytes()).unwrap(),
|
||||
backend_handler: AccessControlledBackendHandler::new(backend_handler),
|
||||
jwt_key: hmac::Mac::new_from_slice(jwt_secret.unsecure().as_bytes()).unwrap(),
|
||||
jwt_blacklist: RwLock::new(jwt_blacklist),
|
||||
server_url,
|
||||
mail_options,
|
||||
}))
|
||||
.route("/health", web::get().to(|| HttpResponse::Ok().finish()))
|
||||
.service(web::scope("/auth").configure(auth_service::configure_server::<Backend>))
|
||||
.route(
|
||||
"/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.
|
||||
.service(
|
||||
web::scope("/api")
|
||||
.wrap(auth_service::CookieToHeaderTranslatorFactory)
|
||||
.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.
|
||||
.service(Files::new("/pkg", "./app/pkg"))
|
||||
// Serve static files
|
||||
@ -95,28 +123,45 @@ fn http_config<Backend>(
|
||||
// Serve static fonts
|
||||
.service(Files::new("/static/fonts", "./app/static/fonts"))
|
||||
// Default to serve index.html for unknown routes, to support routing.
|
||||
.service(
|
||||
web::scope("/")
|
||||
.route("", web::get().to(index)) // this is necessary because the below doesn't match a request for "/"
|
||||
.route(".*", web::get().to(index)),
|
||||
);
|
||||
.default_service(web::route().guard(guard::Get()).to(index));
|
||||
}
|
||||
|
||||
pub(crate) struct AppState<Backend> {
|
||||
pub backend_handler: Backend,
|
||||
pub backend_handler: AccessControlledBackendHandler<Backend>,
|
||||
pub jwt_key: Hmac<Sha512>,
|
||||
pub jwt_blacklist: RwLock<HashSet<u64>>,
|
||||
pub server_url: String,
|
||||
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>(
|
||||
config: &Configuration,
|
||||
backend_handler: Backend,
|
||||
server_builder: ServerBuilder,
|
||||
) -> Result<ServerBuilder>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
|
||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
|
||||
{
|
||||
let jwt_secret = config.jwt_secret.clone();
|
||||
let jwt_blacklist = backend_handler
|
||||
@ -125,6 +170,7 @@ where
|
||||
.context("while getting the jwt blacklist")?;
|
||||
let server_url = config.http_url.clone();
|
||||
let mail_options = config.smtp_options.clone();
|
||||
let verbose = config.verbose;
|
||||
info!("Starting the API/web server on port {}", config.http_port);
|
||||
server_builder
|
||||
.bind(
|
||||
@ -136,10 +182,13 @@ where
|
||||
let jwt_blacklist = jwt_blacklist.clone();
|
||||
let server_url = server_url.clone();
|
||||
let mail_options = mail_options.clone();
|
||||
HttpServiceBuilder::new()
|
||||
HttpServiceBuilder::default()
|
||||
.finish(map_config(
|
||||
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| {
|
||||
http_config(
|
||||
cfg,
|
||||
|
@ -1,12 +1,16 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![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 crate::{
|
||||
domain::{
|
||||
handler::{CreateUserRequest, GroupBackendHandler, GroupRequestFilter, UserBackendHandler},
|
||||
handler::{
|
||||
CreateUserRequest, GroupBackendHandler, GroupListerBackendHandler, GroupRequestFilter,
|
||||
UserBackendHandler,
|
||||
},
|
||||
sql_backend_handler::SqlBackendHandler,
|
||||
sql_opaque_handler::register_password,
|
||||
},
|
||||
@ -158,6 +162,8 @@ fn run_healthcheck(opts: RunOpts) -> Result<()> {
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
info!("Starting healthchecks");
|
||||
|
||||
use tokio::time::timeout;
|
||||
let delay = Duration::from_millis(3000);
|
||||
let (ldap, ldaps, api) = runtime.block_on(async {
|
||||
@ -168,17 +174,53 @@ fn run_healthcheck(opts: RunOpts) -> Result<()> {
|
||||
)
|
||||
});
|
||||
|
||||
let mut failure = false;
|
||||
[ldap, ldaps, api]
|
||||
let failure = [ldap, ldaps, api]
|
||||
.into_iter()
|
||||
.filter_map(Result::err)
|
||||
.for_each(|e| {
|
||||
failure = true;
|
||||
error!("{:#}", e)
|
||||
});
|
||||
.flat_map(|res| {
|
||||
if let Err(e) = &res {
|
||||
error!("Error running the health check: {:#}", e);
|
||||
}
|
||||
res
|
||||
})
|
||||
.any(|r| r.is_err());
|
||||
if failure {
|
||||
error!("Healthcheck failed");
|
||||
}
|
||||
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<()> {
|
||||
let cli_opts = infra::cli::init();
|
||||
match cli_opts.command {
|
||||
@ -186,5 +228,6 @@ fn main() -> Result<()> {
|
||||
Command::Run(opts) => run_server_command(opts),
|
||||
Command::HealthCheck(opts) => run_healthcheck(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