You've already forked lldap
mirror of
https://github.com/lldap/lldap.git
synced 2026-04-05 20:42:57 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a627e69e46 |
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.74
|
||||
FROM rust:1.72
|
||||
|
||||
ARG USERNAME=lldapdev
|
||||
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
||||
|
||||
+1
-10
@@ -1,19 +1,10 @@
|
||||
codecov:
|
||||
require_ci_to_pass: yes
|
||||
comment:
|
||||
layout: "header,diff,files"
|
||||
layout: "diff,flags"
|
||||
require_changes: true
|
||||
require_base: true
|
||||
require_head: true
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: "75%"
|
||||
threshold: "0.1%"
|
||||
removed_code_behavior: adjust_base
|
||||
github_checks:
|
||||
annotations: true
|
||||
ignore:
|
||||
- "app"
|
||||
- "docs"
|
||||
|
||||
@@ -59,12 +59,12 @@ RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM alpine:3.19
|
||||
FROM alpine:3.16
|
||||
WORKDIR /app
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apk add --no-cache tini ca-certificates bash tzdata jq curl jo && \
|
||||
RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
||||
addgroup -g $GID $USER && \
|
||||
adduser \
|
||||
--disabled-password \
|
||||
@@ -80,6 +80,5 @@ COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
WORKDIR /app
|
||||
COPY scripts/bootstrap.sh ./
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
|
||||
@@ -65,7 +65,7 @@ ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini openssl ca-certificates tzdata jq curl jo && \
|
||||
apt install -y --no-install-recommends tini openssl ca-certificates tzdata && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||
@@ -74,7 +74,6 @@ COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
COPY scripts/bootstrap.sh ./
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Keep tracking base image
|
||||
FROM rust:1.85-slim-bookworm
|
||||
FROM rust:1.74-slim-bookworm
|
||||
|
||||
# Set needed env path
|
||||
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||
@@ -34,8 +34,7 @@ RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
||||
### Add musl target
|
||||
RUN rustup target add x86_64-unknown-linux-musl && \
|
||||
rustup target add aarch64-unknown-linux-musl && \
|
||||
rustup target add armv7-unknown-linux-musleabihf && \
|
||||
rustup target add x86_64-unknown-freebsd
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
|
||||
|
||||
CMD ["bash"]
|
||||
|
||||
@@ -39,7 +39,7 @@ env:
|
||||
# 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 #
|
||||
# lldap/rust-dev #
|
||||
# lldap/rust-dev:latest #
|
||||
#######################################################################################
|
||||
# Cargo build
|
||||
### armv7, aarch64 and amd64 is musl based
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
image: lldap/rust-dev:latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -216,8 +216,6 @@ jobs:
|
||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
|
||||
LLDAP_ldap_port: 3890
|
||||
LLDAP_http_port: 17170
|
||||
LLDAP_JWT_SECRET: verysecret
|
||||
LLDAP_LDAP_USER_PASS: password
|
||||
|
||||
|
||||
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
|
||||
@@ -229,8 +227,6 @@ jobs:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
|
||||
LLDAP_ldap_port: 3891
|
||||
LLDAP_http_port: 17171
|
||||
LLDAP_JWT_SECRET: verysecret
|
||||
LLDAP_LDAP_USER_PASS: password
|
||||
|
||||
|
||||
- name: Run lldap with sqlite DB and healthcheck
|
||||
@@ -242,8 +238,6 @@ jobs:
|
||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||
LLDAP_ldap_port: 3892
|
||||
LLDAP_http_port: 17172
|
||||
LLDAP_JWT_SECRET: verysecret
|
||||
LLDAP_LDAP_USER_PASS: password
|
||||
|
||||
- name: Check DB container logs
|
||||
run: |
|
||||
@@ -300,7 +294,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
sparse-checkout: 'scripts'
|
||||
|
||||
@@ -330,9 +324,9 @@ jobs:
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_DATABASE_URL: sqlite://users.db?mode=rwc
|
||||
LLDAP_LDAP_PORT: 3890
|
||||
LLDAP_HTTP_PORT: 17170
|
||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||
LLDAP_ldap_port: 3890
|
||||
LLDAP_http_port: 17170
|
||||
LLDAP_LDAP_USER_PASS: ldappass
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
@@ -356,11 +350,8 @@ jobs:
|
||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e ":a; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\3/; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\3/; ta" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
|
||||
- name: Create schema on postgres
|
||||
env:
|
||||
LLDAP_DATABASE_URL: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
run: |
|
||||
bin/lldap create_schema
|
||||
bin/lldap create_schema -d postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
|
||||
- name: Copy converted db to postgress and import
|
||||
run: |
|
||||
@@ -377,10 +368,7 @@ jobs:
|
||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||
|
||||
- name: Create schema on mariadb
|
||||
env:
|
||||
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
run: bin/lldap create_schema
|
||||
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
|
||||
- name: Copy converted db to mariadb and import
|
||||
run: |
|
||||
@@ -396,10 +384,7 @@ jobs:
|
||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||
|
||||
- name: Create schema on mysql
|
||||
env:
|
||||
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
run: bin/lldap create_schema
|
||||
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
|
||||
- name: Copy converted db to mysql and import
|
||||
run: |
|
||||
@@ -414,9 +399,10 @@ jobs:
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_DATABASE_URL: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
LLDAP_LDAP_PORT: 3891
|
||||
LLDAP_HTTP_PORT: 17171
|
||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
LLDAP_ldap_port: 3891
|
||||
LLDAP_http_port: 17171
|
||||
LLDAP_LDAP_USER_PASS: ldappass
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Run lldap with mariaDB and healthcheck again
|
||||
@@ -425,9 +411,9 @@ jobs:
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
LLDAP_LDAP_PORT: 3892
|
||||
LLDAP_HTTP_PORT: 17172
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
LLDAP_ldap_port: 3892
|
||||
LLDAP_http_port: 17172
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Run lldap with mysql and healthcheck again
|
||||
@@ -436,9 +422,9 @@ jobs:
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
LLDAP_LDAP_PORT: 3893
|
||||
LLDAP_HTTP_PORT: 17173
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
LLDAP_ldap_port: 3893
|
||||
LLDAP_http_port: 17173
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Test Dummy User Postgres
|
||||
@@ -496,7 +482,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -526,7 +512,7 @@ jobs:
|
||||
tags: ${{ matrix.container }}-base
|
||||
|
||||
- name: Build ${{ matrix.container }} Base Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# On PR will fail, force fully uncomment push: true, or docker image will fail for next steps
|
||||
@@ -627,7 +613,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build ${{ matrix.container }}-rootless Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
@@ -641,7 +627,7 @@ jobs:
|
||||
|
||||
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
|
||||
- name: Build ${{ matrix.container }} Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
@@ -655,7 +641,7 @@ jobs:
|
||||
|
||||
- name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -663,7 +649,7 @@ jobs:
|
||||
|
||||
- name: Update lldap repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -752,9 +738,5 @@ jobs:
|
||||
artifacts: aarch64-lldap.tar.gz,
|
||||
amd64-lldap.tar.gz,
|
||||
armhf-lldap.tar.gz
|
||||
draft: true
|
||||
omitBodyDuringUpdate: true
|
||||
omitDraftDuringUpdate: true
|
||||
omitNameDuringUpdate: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Release Bot
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: nflaig/release-comment-on-pr@master
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_BOT_TOKEN }}
|
||||
message: |
|
||||
Thank you everyone for the contribution!
|
||||
This feature is now available in the latest release, [${releaseTag}](${releaseUrl}).
|
||||
You can support LLDAP by starring our repo, contributing some configuration examples and becoming a sponsor.
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||
@@ -101,10 +101,16 @@ jobs:
|
||||
run: cargo llvm-cov --workspace --no-report
|
||||
- name: Aggregate reports
|
||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||
- name: Upload coverage to Codecov (main)
|
||||
uses: codecov/codecov-action@v4
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
- name: Upload coverage to Codecov (main)
|
||||
uses: codecov/codecov-action@v3
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
codecov_yml_path: .github/codecov.yml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
+1
-102
@@ -5,107 +5,6 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.1] 2024-11-22
|
||||
|
||||
Small release, mainly to fix a migration issue with Sqlite and Postgresql.
|
||||
|
||||
### Added
|
||||
|
||||
- Added a link to a community terraform provider (#1035)
|
||||
|
||||
### Changed
|
||||
|
||||
- The opaque dependency now points to the official crate rather than a fork (#1040)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Migration of the DB schema from 7 to 8 is now automatic for sqlite, and fixed for postgres (#1045)
|
||||
- The startup warning about `key_seed` applying instead of `key_file` now has instructions on how to silence it (#1032)
|
||||
|
||||
### New services
|
||||
|
||||
- OneDev
|
||||
|
||||
## [0.6.0] 2024-11-09
|
||||
|
||||
### Breaking
|
||||
|
||||
- The endpoint `/auth/reset/step1` is now `POST` instead of `GET` (#704)
|
||||
|
||||
### Added
|
||||
|
||||
- Custom attributes are now supported (#67) ! You can add new fields (string, integers, JPEG or dates) to users and query them. That unlocks many integrations with other services, and allows for a deeper/more customized integration. Special thanks to @pixelrazor and @bojidar-bg for their help with the UI.
|
||||
- Custom object classes (for all users/groups) can now be added (#833)
|
||||
- Barebones support for Paged Results Control (no paging, no respect for windows, but a correct response with all the results) (#698)
|
||||
- A daily docker image is tagged and released. (#613)
|
||||
- A bootstrap script allows reading the list of users/groups from a file and making sure the server contains exactly the same thing. (#654)
|
||||
- Make it possible to serve lldap behind a sub-path in (#752)
|
||||
- LLDAP can now be found on a custom package repository for opensuse, fedora, ubuntu, debian and centos ([Repository link](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap)). Thanks @Masgalor for setting it up and maintaining it.
|
||||
- There's now an option to force reset the admin password (#748) optionally on every restart (#959)
|
||||
- There's a rootless docker container (#755)
|
||||
- entryDN is now supported (#780)
|
||||
- Unknown LDAP controls are now detected and ignored (#787, #799)
|
||||
- A community-developed CLI for scripting (#793)
|
||||
- Added a way to print raw logs to debug long-running sessions (#992)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- The official docker repository is now `lldap/lldap`
|
||||
- Removed password length limitation in lldap_set_password tool
|
||||
- Group names and emails are now case insensitive, but keep their casing (#666)
|
||||
- Better error messages (and exit code (#745)) when changing the private key (#778, #1008), using the wrong SMTP port (#970), using the wrong env variables (#972)
|
||||
- Allow `member=` filters with plain user names (not full DNs) (#949)
|
||||
- Correctly detect and refuse anonymous binds (#974)
|
||||
- Clearer logging (#971, #981, #982)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Logging out applies globally, not just in the local browser. (#721)
|
||||
- It's no longer possible to create the same user twice (#745)
|
||||
- Fix wide substring filters (#738)
|
||||
- Don't log the database password if provided in the connection URL (#735)
|
||||
- Fix a panic when postgres uses a different collation (#821)
|
||||
- The UI now defaults to the user ID for users with no display names (#843)
|
||||
- Fix searching for users with more than one `memberOf` filter (#872)
|
||||
- Fix compilation on Windows (#932) and Illumos (#964)
|
||||
- The UI now correctly detects whether password resets are enabled. (#753)
|
||||
- Fix a missing lowercasing of username when changing passwords through LDAP (#1012)
|
||||
- Fix SQLite writers erroring when racing (#1021)
|
||||
- LDAP sessions no longer buffer their logs until unbind, causing memory leaks (#1025)
|
||||
|
||||
### Performance
|
||||
|
||||
- Only expand attributes once per query, not per result (#687)
|
||||
|
||||
### Security
|
||||
|
||||
- When asked to send a password reset to an unknown email, sleep for 3 seconds and don't print the email in the error (#887)
|
||||
|
||||
### New services
|
||||
|
||||
Linux user accounts can now be managed by LLDAP, using PAM and nslcd.
|
||||
|
||||
- Apereo CAS server
|
||||
- Carpal
|
||||
- Gitlab
|
||||
- Grocy
|
||||
- Harbor
|
||||
- Home Assistant
|
||||
- Jenkins
|
||||
- Kasm
|
||||
- Maddy
|
||||
- Mastodon
|
||||
- Metabase
|
||||
- MegaRAC-BMC
|
||||
- Netbox
|
||||
- OCIS
|
||||
- Prosody
|
||||
- Radicale
|
||||
- SonarQube
|
||||
- Traccar
|
||||
- Zitadel
|
||||
|
||||
## [0.5.0] 2023-09-14
|
||||
|
||||
### Breaking
|
||||
@@ -172,7 +71,7 @@ systems, including PAM authentication.
|
||||
## [0.4.3] 2023-04-11
|
||||
|
||||
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
|
||||
and on DockerHub (although we will keep publishing the images to
|
||||
and on DockerHub (although we will keep publishing the images to
|
||||
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
|
||||
migrated, and the new docker images are available both on DockerHub and on the
|
||||
GHCR under `lldap/lldap`.
|
||||
|
||||
Generated
+1152
-1662
File diff suppressed because it is too large
Load Diff
+12
-21
@@ -1,21 +1,15 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"server",
|
||||
"app",
|
||||
"migration-tool",
|
||||
"set-password",
|
||||
"crates/*",
|
||||
"server",
|
||||
"auth",
|
||||
"app",
|
||||
"migration-tool",
|
||||
"set-password",
|
||||
]
|
||||
default-members = ["server"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
documentation = "https://github.com/lldap/lldap"
|
||||
edition = "2024"
|
||||
homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
default-members = ["server"]
|
||||
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
@@ -23,12 +17,9 @@ 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/'
|
||||
|
||||
[workspace.dependencies.sea-orm]
|
||||
version = "1.1.8"
|
||||
default-features = false
|
||||
|
||||
[workspace.dependencies.serde]
|
||||
version = "1"
|
||||
|
||||
+3
-4
@@ -1,5 +1,5 @@
|
||||
# Build image
|
||||
FROM rust:alpine3.21 AS chef
|
||||
FROM rust:alpine3.16 AS chef
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
@@ -41,9 +41,9 @@ RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password
|
||||
&& ./app/build.sh
|
||||
|
||||
# Final image
|
||||
FROM alpine:3.21
|
||||
FROM alpine:3.16
|
||||
|
||||
ENV GOSU_VERSION=1.14
|
||||
ENV GOSU_VERSION 1.14
|
||||
# Fetch gosu from git
|
||||
RUN set -eux; \
|
||||
\
|
||||
@@ -80,7 +80,6 @@ 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/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||
COPY scripts/bootstrap.sh ./
|
||||
|
||||
RUN set -x \
|
||||
&& apk add --no-cache bash tzdata \
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
build-dev-container:
|
||||
docker buildx build --tag lldap/rust-dev --file .github/workflows/Dockerfile.dev --push .github/workflows
|
||||
|
||||
prepare-release:
|
||||
./prepare-release.sh
|
||||
@@ -34,14 +34,27 @@
|
||||
</p>
|
||||
|
||||
- [About](#about)
|
||||
- [Installation](docs/install.md)
|
||||
- [Installation](#installation)
|
||||
- [With Docker](#with-docker)
|
||||
- [With Kubernetes](#with-kubernetes)
|
||||
- [From a package repository](#from-a-package-repository)
|
||||
- [From source](#from-source)
|
||||
- [Backend](#backend)
|
||||
- [Frontend](#frontend)
|
||||
- [Cross-compilation](#cross-compilation)
|
||||
- [Usage](#usage)
|
||||
- [Recommended architecture](#recommended-architecture)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Known compatible services](#known-compatible-services)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Sample client configurations](#sample-client-configurations)
|
||||
- [Incompatible services](#incompatible-services)
|
||||
- [Frequently Asked Questions](#frequently-asked-questions)
|
||||
- [Migrating from SQLite](#migrating-from-sqlite)
|
||||
- [Comparisons with other services](#comparisons-with-other-services)
|
||||
- [vs OpenLDAP](#vs-openldap)
|
||||
- [vs FreeIPA](#vs-freeipa)
|
||||
- [vs Kanidm](#vs-kanidm)
|
||||
- [I can't log in!](#i-cant-log-in)
|
||||
- [Contributions](#contributions)
|
||||
|
||||
## About
|
||||
@@ -83,9 +96,179 @@ MySQL/MariaDB or PostgreSQL.
|
||||
|
||||
## Installation
|
||||
|
||||
It's possible to install lldap from OCI images ([docker](docs/install.md#with-docker)/[podman](docs/install.md#with-podman)), from [Kubernetes](docs/install.md#with-kubernetes), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD).
|
||||
### With Docker
|
||||
|
||||
Building [from source](docs/install.md#from-source) and [cross-compiling](docs/install.md#cross-compilation) to a different hardware architecture is also supported.
|
||||
The image is available at `lldap/lldap`. You should persist the `/data`
|
||||
folder, which contains your configuration and the SQLite database (you can
|
||||
remove this step if you use a different DB and configure with environment
|
||||
variables only).
|
||||
|
||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||
`jwt_secret` and `ldap_user_pass`, unless you override them with env variables).
|
||||
Environment variables should be prefixed with `LLDAP_` to override the
|
||||
configuration.
|
||||
|
||||
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use
|
||||
default one. The default admin password is `password`, you can change the
|
||||
password later using the web interface.
|
||||
|
||||
Secrets can also be set through a file. The filename should be specified by the
|
||||
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_KEY_SEED_FILE`, and the file
|
||||
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 generate the secrets by running `./generate_secrets.sh`
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: lldap/lldap:stable
|
||||
ports:
|
||||
# For LDAP, not recommended to expose, see Usage section.
|
||||
#- "3890:3890"
|
||||
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
|
||||
#- "6360:6360"
|
||||
# For the web front-end
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
- "lldap_data:/data"
|
||||
# Alternatively, you can mount a local folder
|
||||
# - "./lldap_data:/data"
|
||||
environment:
|
||||
- UID=####
|
||||
- GID=####
|
||||
- TZ=####/####
|
||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
# If using LDAPS, set enabled true and configure cert and key path
|
||||
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
||||
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
|
||||
# You can also set a different database:
|
||||
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
You can bootstrap your lldap instance (users, groups)
|
||||
using [bootstrap.sh](example_configs/bootstrap/bootstrap.md#kubernetes-job).
|
||||
It can be run by Argo CD for managing users in git-opt way, or as a one-shot job.
|
||||
|
||||
### From a package repository
|
||||
|
||||
**Do not open issues in this repository for problems with third-party
|
||||
pre-built packages. Report issues downstream.**
|
||||
|
||||
Depending on the distribution you use, it might be possible to install lldap
|
||||
from a package repository, officially supported by the distribution or
|
||||
community contributed.
|
||||
|
||||
#### Debian, CentOS Fedora, OpenSUSE, Ubuntu
|
||||
|
||||
The package for these distributions can be found at [LLDAP OBS](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap).
|
||||
- When using the distributed package, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
||||
|
||||
#### Arch Linux
|
||||
|
||||
Arch Linux offers unofficial support through the [Arch User Repository
|
||||
(AUR)](https://wiki.archlinux.org/title/Arch_User_Repository).
|
||||
Available package descriptions in AUR are:
|
||||
|
||||
- [lldap](https://aur.archlinux.org/packages/lldap) - Builds the latest stable version.
|
||||
- [lldap-bin](https://aur.archlinux.org/packages/lldap-bin) - Uses the latest
|
||||
pre-compiled binaries from the [releases in this repository](https://github.com/lldap/lldap/releases).
|
||||
This package is recommended if you want to run lldap on a system with
|
||||
limited resources.
|
||||
- [lldap-git](https://aur.archlinux.org/packages/lldap-git) - Builds the
|
||||
latest main branch code.
|
||||
|
||||
The package descriptions can be used
|
||||
[to create and install packages](https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started).
|
||||
Each package places lldap's configuration file at `/etc/lldap.toml` and offers
|
||||
[systemd service](https://wiki.archlinux.org/title/systemd#Using_units)
|
||||
`lldap.service` to (auto-)start and stop lldap.
|
||||
|
||||
### From source
|
||||
|
||||
#### Backend
|
||||
|
||||
To compile the project, you'll need:
|
||||
|
||||
- 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):
|
||||
|
||||
```shell
|
||||
cargo build --release -p lldap -p lldap_migration_tool
|
||||
```
|
||||
|
||||
The resulting binaries will be in `./target/release/`. Alternatively, you can
|
||||
just run `cargo run -- run` to run the server.
|
||||
|
||||
#### Frontend
|
||||
|
||||
To bring up the server, you'll need to compile the frontend. In addition to
|
||||
`cargo`, you'll need WASM-pack, which can be installed by running `cargo install wasm-pack`.
|
||||
|
||||
Then you can build the frontend files with
|
||||
|
||||
```shell
|
||||
./app/build.sh
|
||||
```
|
||||
|
||||
(you'll need to run this after every front-end change to update the WASM
|
||||
package served).
|
||||
|
||||
The default config is in `src/infra/configuration.rs`, but you can override it
|
||||
by creating an `lldap_config.toml`, setting environment variables or passing
|
||||
arguments to `cargo run`. Have a look at the docker template:
|
||||
`lldap_config.docker_template.toml`.
|
||||
|
||||
You can also install it as a systemd service, see
|
||||
[lldap.service](example_configs/lldap.service).
|
||||
|
||||
### Cross-compilation
|
||||
|
||||
Docker images are provided for AMD64, ARM64 and ARM/V7.
|
||||
|
||||
If you want to cross-compile yourself, you can do so by installing
|
||||
[`cross`](https://github.com/rust-embedded/cross):
|
||||
|
||||
```sh
|
||||
cargo install cross
|
||||
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
|
||||
./app/build.sh
|
||||
```
|
||||
|
||||
(Replace `armv7-unknown-linux-musleabihf` with the correct Rust target for your
|
||||
device.)
|
||||
|
||||
You can then get the compiled server binary in
|
||||
`target/armv7-unknown-linux-musleabihf/release/lldap` and the various needed files
|
||||
(`index.html`, `main.js`, `pkg` folder) in the `app` folder. Copy them to the
|
||||
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||
files in an `app` folder next to the binary).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -94,16 +277,10 @@ create users, set passwords, add them to groups and so on. Users can also
|
||||
connect to the web UI and change their information, or request a password reset
|
||||
link (if you configured the SMTP client).
|
||||
|
||||
You can create and manage custom attributes through the Web UI, or through the
|
||||
community-contributed CLI frontend (
|
||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli)). This is necessary
|
||||
for some service integrations.
|
||||
|
||||
The [bootstrap.sh](scripts/bootstrap.sh) script can enforce a list of
|
||||
users/groups/attributes from a given file, reflecting it on the server.
|
||||
|
||||
To manage the user, group and membership lifecycle in an infrastructure-as-code
|
||||
scenario you can use the unofficial [LLDAP terraform provider in the terraform registry](https://registry.terraform.io/providers/tasansga/lldap/latest).
|
||||
Creating and managing custom attributes is currently in Beta. It's not
|
||||
supported in the Web UI. The recommended way is to use
|
||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli), a
|
||||
community-contributed CLI frontend.
|
||||
|
||||
LLDAP is also very scriptable, through its GraphQL API. See the
|
||||
[Scripting](docs/scripting.md) docs for more info.
|
||||
@@ -136,7 +313,7 @@ If you are using containers, a sample architecture could look like this:
|
||||
|
||||
## Client configuration
|
||||
|
||||
### Known compatible services
|
||||
### Compatible services
|
||||
|
||||
Most services that can use LDAP as an authentication provider should work out
|
||||
of the box. For new services, it's possible that they require a bit of tweaking
|
||||
@@ -144,13 +321,6 @@ on LLDAP's side to make things work. In that case, just create an issue with
|
||||
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
|
||||
the config).
|
||||
|
||||
Some specific clients have been tested to work and come with sample
|
||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||
folder for example configs for integration with specific services.
|
||||
|
||||
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
|
||||
configuration guide](example_configs/pam/README.md). Integration with Windows (e.g. Samba) is WIP.
|
||||
|
||||
### General configuration guide
|
||||
|
||||
To configure the services that will talk to LLDAP, here are the values:
|
||||
@@ -170,9 +340,65 @@ filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
||||
admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
||||
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||
administration access to many services. To prevent privilege escalation users in the
|
||||
`lldap_password_manager` group are not allowed to change passwords of admins in the
|
||||
`lldap_admin` group.
|
||||
administration access to many services.
|
||||
|
||||
### Sample client configurations
|
||||
|
||||
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)
|
||||
- [Apereo CAS Server](example_configs/apereo_cas_server.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Authentik](example_configs/authentik.md)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [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)
|
||||
- [Ejabberd](example_configs/ejabberd.md)
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Ergo IRCd](example_configs/ergo.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [GitLab](example_configs/gitlab.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Grocy](example_configs/grocy.md)
|
||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||
- [Home Assistant](example_configs/home-assistant.md)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jenkins](example_configs/jenkins.md)
|
||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||
- [Kasm](example_configs/kasm.md)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [LibreNMS](example_configs/librenms.md)
|
||||
- [Mastodon](example_configs/mastodon.env.example)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Mealie](example_configs/mealie.md)
|
||||
- [MinIO](example_configs/minio.md)
|
||||
- [Nextcloud](example_configs/nextcloud.md)
|
||||
- [Nexus](example_configs/nexus.md)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
||||
- [Proxmox VE](example_configs/proxmox.md)
|
||||
- [Rancher](example_configs/rancher.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Shaarli](example_configs/shaarli.md)
|
||||
- [Squid](example_configs/squid.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [TheLounge](example_configs/thelounge.md)
|
||||
- [Traccar](example_configs/traccar.xml)
|
||||
- [Vaultwarden](example_configs/vaultwarden.md)
|
||||
- [WeKan](example_configs/wekan.md)
|
||||
- [WG Portal](example_configs/wg_portal.env.example)
|
||||
- [WikiJS](example_configs/wikijs.md)
|
||||
- [XBackBone](example_configs/xbackbone_config.php)
|
||||
- [Zendto](example_configs/zendto.md)
|
||||
- [Zitadel](example_configs/zitadel.md)
|
||||
- [Zulip](example_configs/zulip.md)
|
||||
|
||||
### Incompatible services
|
||||
|
||||
@@ -195,16 +421,76 @@ it duplicates the places from which a password hash could leak.
|
||||
In that category, the most prominent is Synology. It is, to date, the only
|
||||
service that seems definitely incompatible with LLDAP.
|
||||
|
||||
## Frequently Asked Questions
|
||||
## Migrating from SQLite
|
||||
|
||||
- [I can't login](docs/faq.md#i-cant-log-in)
|
||||
- [Discord Integration](docs/faq.md#discord-integration)
|
||||
- [Migrating from SQLite](docs/faq.md#migrating-from-sqlite)
|
||||
- How does lldap compare [with OpenLDAP](docs/faq.md#how-does-lldap-compare-with-openldap)? [With FreeIPA](docs/faq.md#how-does-lldap-compare-with-freeipa)? [With Kanidm]?(docs/faq.md#how-does-lldap-compare-with-kanidm)
|
||||
- [Does lldap support vhosts?](docs/faq.md#does-lldap-support-vhosts)
|
||||
- [Does lldap provide commercial support contracts?](docs/faq.md#does-lldap-provide-commercial-support-contracts)
|
||||
- [Can I make a donation to fund development?](docs/faq.md#can-i-make-a-donation-to-fund-development)
|
||||
- [Is lldap sustainable? Can we depend on it for our infrastructure?](docs/faq.md#is-lldap-sustainable-can-we-depend-on-it-for-our-infrastructure)
|
||||
If you started with an SQLite database and would like to migrate to
|
||||
MySQL/MariaDB or PostgreSQL, check out the [DB
|
||||
migration docs](/docs/database_migration.md).
|
||||
|
||||
## Comparisons with other services
|
||||
|
||||
### vs OpenLDAP
|
||||
|
||||
[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 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](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.
|
||||
|
||||
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.
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
+12
-36
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "lldap_app"
|
||||
version = "0.6.2-alpha"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
description = "Frontend for LLDAP"
|
||||
edition.workspace = true
|
||||
edition = "2021"
|
||||
homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap_app"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.5.1-alpha"
|
||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
@@ -19,11 +19,12 @@ 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 = "0.14"
|
||||
wasm-bindgen = "0.2.100"
|
||||
validator = "=0.14"
|
||||
validator_derive = "*"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "*"
|
||||
yew = "0.19.3"
|
||||
yew-router = "0.16"
|
||||
@@ -36,16 +37,12 @@ version = "0.3"
|
||||
features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"Event",
|
||||
"FileReader",
|
||||
"FormData",
|
||||
"HtmlDocument",
|
||||
"HtmlFormElement",
|
||||
"HtmlInputElement",
|
||||
"HtmlOptionElement",
|
||||
"HtmlOptionsCollection",
|
||||
"HtmlSelectElement",
|
||||
"SubmitEvent",
|
||||
"console",
|
||||
]
|
||||
|
||||
@@ -56,23 +53,14 @@ features = [
|
||||
]
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../crates/auth"
|
||||
path = "../auth"
|
||||
features = [ "opaque_client" ]
|
||||
|
||||
[dependencies.lldap_frontend_options]
|
||||
path = "../crates/frontend-options"
|
||||
|
||||
[dependencies.lldap_validation]
|
||||
path = "../crates/validation"
|
||||
|
||||
[dependencies.image]
|
||||
features = ["jpeg"]
|
||||
default-features = false
|
||||
version = "0.24"
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
[dependencies.yew_form]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
@@ -83,15 +71,3 @@ rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = [
|
||||
'cfg(wasm_bindgen_unstable_test_coverage)',
|
||||
] }
|
||||
|
||||
[package.metadata.wasm-pack.profile.dev]
|
||||
wasm-opt = ['--enable-bulk-memory']
|
||||
[package.metadata.wasm-pack.profile.profiling]
|
||||
wasm-opt = ['--enable-bulk-memory']
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = ['--enable-bulk-memory']
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mutation CreateGroup($group: CreateGroupInput!) {
|
||||
createGroupWithDetails(request: $group) {
|
||||
mutation CreateGroup($name: String!) {
|
||||
createGroup(name: $name) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!) {
|
||||
addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: false) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
|
||||
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation DeleteGroupAttributeQuery($name: String!) {
|
||||
deleteGroupAttribute(name: $name) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation DeleteUserAttributeQuery($name: String!) {
|
||||
deleteUserAttribute(name: $name) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
query GetGroupAttributesSchema {
|
||||
schema {
|
||||
groupSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,5 @@ query GetGroupDetails($id: Int!) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
attributes {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
schema {
|
||||
groupSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
query GetUserAttributesSchema {
|
||||
schema {
|
||||
userSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,30 +2,15 @@ query GetUserDetails($id: String!) {
|
||||
user(userId: $id) {
|
||||
id
|
||||
email
|
||||
avatar
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatar
|
||||
creationDate
|
||||
uuid
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
attributes {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
schema {
|
||||
userSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation UpdateGroup($group: UpdateGroupInput!) {
|
||||
updateGroup(group: $group) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,13 +155,8 @@ impl Component for AddGroupMemberComponent {
|
||||
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||
#[allow(unused_braces)]
|
||||
let make_select_option = |user: User| {
|
||||
let name = if user.display_name.is_empty() {
|
||||
user.id.clone()
|
||||
} else {
|
||||
user.display_name.clone()
|
||||
};
|
||||
html_nested! {
|
||||
<SelectOption value={user.id.clone()} text={name} key={user.id} />
|
||||
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
|
||||
}
|
||||
};
|
||||
html! {
|
||||
|
||||
+133
-55
@@ -1,38 +1,54 @@
|
||||
use crate::{
|
||||
components::{
|
||||
banner::Banner,
|
||||
change_password::ChangePasswordForm,
|
||||
create_group::CreateGroupForm,
|
||||
create_group_attribute::CreateGroupAttributeForm,
|
||||
create_user::CreateUserForm,
|
||||
create_user_attribute::CreateUserAttributeForm,
|
||||
group_details::GroupDetails,
|
||||
group_schema_table::ListGroupSchema,
|
||||
group_table::GroupTable,
|
||||
login::LoginForm,
|
||||
logout::LogoutButton,
|
||||
reset_password_step1::ResetPasswordStep1Form,
|
||||
reset_password_step2::ResetPasswordStep2Form,
|
||||
router::{AppRoute, Link, Redirect},
|
||||
user_details::UserDetails,
|
||||
user_schema_table::ListUserSchema,
|
||||
user_table::UserTable,
|
||||
},
|
||||
infra::{api::HostService, cookies::get_cookie},
|
||||
};
|
||||
|
||||
use gloo_console::error;
|
||||
use lldap_frontend_options::Options;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use yew::{
|
||||
Context, function_component,
|
||||
function_component,
|
||||
html::Scope,
|
||||
prelude::{Component, Html, html},
|
||||
prelude::{html, Component, Html},
|
||||
Context,
|
||||
};
|
||||
use yew_router::{
|
||||
BrowserRouter, Switch,
|
||||
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! {
|
||||
@@ -51,7 +67,7 @@ pub struct App {
|
||||
pub enum Msg {
|
||||
Login((String, bool)),
|
||||
Logout,
|
||||
SettingsReceived(anyhow::Result<Options>),
|
||||
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||
}
|
||||
|
||||
impl Component for App {
|
||||
@@ -76,8 +92,9 @@ impl Component for App {
|
||||
redirect_to: Self::get_redirect_route(ctx),
|
||||
password_reset_enabled: None,
|
||||
};
|
||||
ctx.link()
|
||||
.send_future(async move { Msg::SettingsReceived(HostService::get_settings().await) });
|
||||
ctx.link().send_future(async move {
|
||||
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
|
||||
});
|
||||
app.apply_initial_redirections(ctx);
|
||||
app
|
||||
}
|
||||
@@ -102,11 +119,14 @@ impl Component for App {
|
||||
self.redirect_to = None;
|
||||
history.push(AppRoute::Login);
|
||||
}
|
||||
Msg::SettingsReceived(Ok(settings)) => {
|
||||
self.password_reset_enabled = Some(settings.password_reset_enabled);
|
||||
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
|
||||
self.password_reset_enabled = Some(enabled);
|
||||
}
|
||||
Msg::SettingsReceived(Err(err)) => {
|
||||
error!(err.to_string());
|
||||
Msg::PasswordResetProbeFinished(Err(err)) => {
|
||||
self.password_reset_enabled = Some(false);
|
||||
error!(&format!(
|
||||
"Could not probe for password reset support: {err:#}"
|
||||
));
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -115,14 +135,13 @@ impl Component for App {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link().clone();
|
||||
let is_admin = self.is_admin();
|
||||
let username = self.user_info.clone().map(|(username, _)| username);
|
||||
let password_reset_enabled = self.password_reset_enabled;
|
||||
html! {
|
||||
<div>
|
||||
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||
{self.view_banner(ctx)}
|
||||
<div class="container py-3 bg-kug">
|
||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||
<main class="py-3">
|
||||
<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))}
|
||||
/>
|
||||
@@ -196,55 +215,29 @@ impl App {
|
||||
AppRoute::CreateUser => html! {
|
||||
<CreateUserForm/>
|
||||
},
|
||||
AppRoute::Index | AppRoute::ListUsers => {
|
||||
let user_button = html! {
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<UserTable />
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Create a user"}
|
||||
</Link>
|
||||
};
|
||||
html! {
|
||||
<div>
|
||||
{ user_button.clone() }
|
||||
<UserTable />
|
||||
{ user_button }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
},
|
||||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::CreateUserAttribute => html! {
|
||||
<CreateUserAttributeForm/>
|
||||
},
|
||||
AppRoute::CreateGroupAttribute => html! {
|
||||
<CreateGroupAttributeForm/>
|
||||
},
|
||||
AppRoute::ListGroups => {
|
||||
let group_button = html! {
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Create a group"}
|
||||
</Link>
|
||||
};
|
||||
// Note: There's a weird bug when switching from the users page to the groups page
|
||||
// where the two groups buttons are at the bottom. I don't know why.
|
||||
html! {
|
||||
<div>
|
||||
{ group_button.clone() }
|
||||
<GroupTable />
|
||||
{ group_button }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AppRoute::ListUserSchema => html! {
|
||||
<ListUserSchema />
|
||||
},
|
||||
AppRoute::ListGroupSchema => html! {
|
||||
<ListGroupSchema />
|
||||
</div>
|
||||
},
|
||||
AppRoute::GroupDetails { group_id } => html! {
|
||||
<GroupDetails group_id={*group_id} is_admin={is_admin} />
|
||||
<GroupDetails group_id={*group_id} />
|
||||
},
|
||||
AppRoute::UserDetails { user_id } => html! {
|
||||
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||
@@ -270,6 +263,91 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_banner(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<header class="p-2 mb-3 border-bottom">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||
<a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
||||
<h2>{"LLDAP"}</h2>
|
||||
</a>
|
||||
|
||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||
{if self.is_admin() { html! {
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListUsers}>
|
||||
<i class="bi-people me-2"></i>
|
||||
{"Users"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListGroups}>
|
||||
<i class="bi-collection me-2"></i>
|
||||
{"Groups"}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
} } else { html!{} } }
|
||||
</ul>
|
||||
{ self.view_user_menu(ctx) }
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_menu(&self, ctx: &Context<Self>) -> Html {
|
||||
if let Some((user_id, _)) = &self.user_info {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="dropdown text-end">
|
||||
<a href="#"
|
||||
class="d-block nav-link text-decoration-none dropdown-toggle"
|
||||
id="dropdownUser"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="currentColor"
|
||||
class="bi bi-person-circle"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
<span class="ms-2">
|
||||
{user_id}
|
||||
</span>
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||
aria-labelledby="dropdownUser1"
|
||||
style="">
|
||||
<li>
|
||||
<Link
|
||||
classes="dropdown-item"
|
||||
to={AppRoute::UserDetails{ user_id: user_id.clone() }}>
|
||||
{"View details"}
|
||||
</Link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<LogoutButton on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_footer(&self) -> Html {
|
||||
html! {
|
||||
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
use crate::infra::functional::{LoadableResult, use_graphql_call};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{Properties, function_component, html, virtual_dom::AttrValue};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_details.graphql",
|
||||
variables_derives = "Clone,PartialEq,Eq",
|
||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetUserDetails;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub user: AttrValue,
|
||||
#[prop_or(32)]
|
||||
pub width: i32,
|
||||
#[prop_or(32)]
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
#[function_component(Avatar)]
|
||||
pub fn avatar(props: &Props) -> Html {
|
||||
let user_details = use_graphql_call::<GetUserDetails>(get_user_details::Variables {
|
||||
id: props.user.to_string(),
|
||||
});
|
||||
|
||||
match &(*user_details) {
|
||||
LoadableResult::Loaded(Ok(response)) => {
|
||||
let avatar = response.user.avatar.clone();
|
||||
match &avatar {
|
||||
Some(data) => html! {
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", data)}
|
||||
style={format!("max-height:{}px;max-width:{}px;height:auto;width:auto;", props.height, props.width)}
|
||||
alt="Avatar" />
|
||||
},
|
||||
None => html! {
|
||||
<BlankAvatarDisplay
|
||||
width={props.width}
|
||||
height={props.height} />
|
||||
},
|
||||
}
|
||||
}
|
||||
LoadableResult::Loaded(Err(error)) => html! {
|
||||
<BlankAvatarDisplay
|
||||
error={error.to_string()}
|
||||
width={props.width}
|
||||
height={props.height} />
|
||||
},
|
||||
LoadableResult::Loading => html! {
|
||||
<BlankAvatarDisplay
|
||||
width={props.width}
|
||||
height={props.height} />
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct BlankAvatarDisplayProps {
|
||||
#[prop_or(None)]
|
||||
pub error: Option<AttrValue>,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
#[function_component(BlankAvatarDisplay)]
|
||||
fn blank_avatar_display(props: &BlankAvatarDisplayProps) -> Html {
|
||||
let fill = match &props.error {
|
||||
Some(_) => "red",
|
||||
None => "currentColor",
|
||||
};
|
||||
html! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width={props.width.to_string()}
|
||||
height={props.height.to_string()}
|
||||
fill={fill}
|
||||
class="bi bi-person-circle"
|
||||
viewBox="0 0 16 16">
|
||||
<title>{props.error.clone().unwrap_or(AttrValue::Static("Avatar"))}</title>
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
use crate::components::{
|
||||
avatar::Avatar,
|
||||
logout::LogoutButton,
|
||||
router::{AppRoute, Link},
|
||||
};
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use yew::{Callback, Properties, function_component, html};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub is_admin: bool,
|
||||
pub username: Option<String>,
|
||||
pub on_logged_out: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(Banner)]
|
||||
pub fn banner(props: &Props) -> Html {
|
||||
html! {
|
||||
<header class="p-2 mb-3 border-bottom">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||
<a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
||||
<h2>{"LLDAP"}</h2>
|
||||
</a>
|
||||
|
||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||
{if props.is_admin { html! {
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListUsers}>
|
||||
<i class="bi-people me-2"></i>
|
||||
{"Users"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListGroups}>
|
||||
<i class="bi-collection me-2"></i>
|
||||
{"Groups"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListUserSchema}>
|
||||
<i class="bi-list-ul me-2"></i>
|
||||
{"User schema"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 h6"
|
||||
to={AppRoute::ListGroupSchema}>
|
||||
<i class="bi-list-ul me-2"></i>
|
||||
{"Group schema"}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
} } else { html!{} } }
|
||||
</ul>
|
||||
<UserMenu username={props.username.clone()} on_logged_out={props.on_logged_out.clone()}/>
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct UserMenuProps {
|
||||
pub username: Option<String>,
|
||||
pub on_logged_out: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(UserMenu)]
|
||||
fn user_menu(props: &UserMenuProps) -> Html {
|
||||
match &props.username {
|
||||
Some(username) => html! {
|
||||
<div class="dropdown text-end">
|
||||
<a href="#"
|
||||
class="d-block nav-link text-decoration-none dropdown-toggle"
|
||||
id="dropdownUser"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<Avatar user={username.clone()} />
|
||||
<span class="ms-2">
|
||||
{username}
|
||||
</span>
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||
aria-labelledby="dropdownUser1"
|
||||
style="">
|
||||
<li>
|
||||
<Link
|
||||
classes="dropdown-item"
|
||||
to={AppRoute::UserDetails{ user_id: username.to_string() }}>
|
||||
{"View details"}
|
||||
</Link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<LogoutButton on_logged_out={props.on_logged_out.clone()} />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
},
|
||||
_ => html! {},
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = darkmode)]
|
||||
fn toggleDarkMode(doSave: bool);
|
||||
|
||||
#[wasm_bindgen]
|
||||
fn inDarkMode() -> bool;
|
||||
}
|
||||
|
||||
#[function_component(DarkModeToggle)]
|
||||
fn dark_mode_toggle() -> Html {
|
||||
html! {
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
|
||||
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
@@ -210,6 +207,7 @@ impl Component for ChangePasswordForm {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let is_admin = ctx.props().is_admin;
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
<div class="mb-2 mt-2">
|
||||
@@ -226,44 +224,90 @@ impl Component for ChangePasswordForm {
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<form class="form">
|
||||
<form
|
||||
class="form">
|
||||
{if !is_admin { html! {
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Current password"
|
||||
field_name="old_password"
|
||||
input_type="password"
|
||||
autocomplete="current-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="form-group row">
|
||||
<label for="old_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Current password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="old_password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="current-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("old_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}} else { html! {} }}
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="New password"
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Confirm password"
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}
|
||||
text="Save changes" >
|
||||
<div class="form-group row mb-3">
|
||||
<label for="new_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"New Password"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Confirm Password"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
<Link
|
||||
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
||||
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
||||
<i class="bi-arrow-return-left me-2"></i>
|
||||
{"Back"}
|
||||
</Link>
|
||||
</Submit>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
field::Field,
|
||||
submit::Submit,
|
||||
},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{
|
||||
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
|
||||
read_all_form_attributes,
|
||||
},
|
||||
schema::AttributeType,
|
||||
},
|
||||
components::router::AppRoute,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Result, ensure};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
@@ -25,33 +10,6 @@ use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_group_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetGroupAttributesSchema;
|
||||
|
||||
use get_group_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute =
|
||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||
|
||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||
fn from(attr: &Attribute) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: false, // Need to be admin to edit it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
@@ -64,8 +22,6 @@ pub struct CreateGroup;
|
||||
pub struct CreateGroupForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateGroupModel>,
|
||||
attributes_schema: Option<Vec<Attribute>>,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
@@ -76,7 +32,6 @@ pub struct CreateGroupModel {
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
SubmitForm,
|
||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||
}
|
||||
@@ -90,33 +45,12 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
ensure!(self.form.validate(), "Check the form for errors");
|
||||
|
||||
let all_values = read_all_form_attributes(
|
||||
self.attributes_schema.iter().flatten(),
|
||||
&self.form_ref,
|
||||
IsAdmin(true),
|
||||
EmailIsRequired(false),
|
||||
)?;
|
||||
let attributes = Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| create_group::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let model = self.form.model();
|
||||
let req = create_group::Variables {
|
||||
group: create_group::CreateGroupInput {
|
||||
displayName: model.groupname,
|
||||
attributes,
|
||||
},
|
||||
name: model.groupname,
|
||||
};
|
||||
self.common.call_graphql::<CreateGroup, _>(
|
||||
ctx,
|
||||
@@ -129,16 +63,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
Msg::CreateGroupResponse(response) => {
|
||||
log!(&format!(
|
||||
"Created group '{}'",
|
||||
&response?.create_group_with_details.display_name
|
||||
&response?.create_group.display_name
|
||||
));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes_schema =
|
||||
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,22 +80,11 @@ impl Component for CreateGroupForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||
attributes_schema: None,
|
||||
form_ref: NodeRef::default(),
|
||||
};
|
||||
component
|
||||
.common
|
||||
.call_graphql::<GetGroupAttributesSchema, _>(
|
||||
ctx,
|
||||
get_group_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch group schema",
|
||||
);
|
||||
component
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
@@ -175,30 +93,44 @@ impl Component for CreateGroupForm {
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<CreateGroupModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px"
|
||||
ref={self.form_ref.clone()}>
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||
</div>
|
||||
<Field<CreateGroupModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Group name"
|
||||
field_name="groupname"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
{
|
||||
self.attributes_schema
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_readonly && a.name != "display_name")
|
||||
.map(get_custom_attribute_input)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
<div class="form-group row mb-3">
|
||||
<label for="groupname"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group name"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="groupname"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="groupname"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("groupname")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
@@ -212,21 +144,3 @@ impl Component for CreateGroupForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::{AttributeType, validate_attribute_type},
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_validation::attributes::validate_attribute_name;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/create_group_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct CreateGroupAttribute;
|
||||
|
||||
convert_attribute_type!(create_group_attribute::AttributeType);
|
||||
|
||||
pub struct CreateGroupAttributeForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateGroupAttributeModel>,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||
pub struct CreateGroupAttributeModel {
|
||||
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||
attribute_name: String,
|
||||
#[validate(custom = "validate_attribute_type")]
|
||||
attribute_type: String,
|
||||
is_list: bool,
|
||||
is_visible: bool, // remove when backend doesn't return group attributes for normal users
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
SubmitForm,
|
||||
CreateGroupAttributeResponse(Result<create_group_attribute::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let model = self.form.model();
|
||||
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
|
||||
let invalid = String::from_iter(invalid_chars);
|
||||
bail!(
|
||||
"Attribute name contains one or more invalid characters: {}",
|
||||
invalid
|
||||
);
|
||||
})?;
|
||||
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||
let req = create_group_attribute::Variables {
|
||||
name: model.attribute_name,
|
||||
attribute_type: create_group_attribute::AttributeType::from(attribute_type),
|
||||
is_list: model.is_list,
|
||||
is_visible: model.is_visible,
|
||||
};
|
||||
self.common.call_graphql::<CreateGroupAttribute, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateGroupAttributeResponse,
|
||||
"Error trying to create group attribute",
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateGroupAttributeResponse(response) => {
|
||||
response?;
|
||||
let model = self.form.model();
|
||||
log!(&format!(
|
||||
"Created group attribute '{}'",
|
||||
model.attribute_name
|
||||
));
|
||||
ctx.link()
|
||||
.history()
|
||||
.unwrap()
|
||||
.push(AppRoute::ListGroupSchema);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CreateGroupAttributeForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
let model = CreateGroupAttributeModel {
|
||||
attribute_type: AttributeType::String.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateGroupAttributeModel>::new(model),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<h5 class="fw-bold">{"Create a group attribute"}</h5>
|
||||
<Field<CreateGroupAttributeModel>
|
||||
label="Name"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Select<CreateGroupAttributeModel>
|
||||
label="Type"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_type"
|
||||
oninput={link.callback(|_| Msg::Update)}>
|
||||
<option selected=true value="String">{"String"}</option>
|
||||
<option value="Integer">{"Integer"}</option>
|
||||
<option value="Jpeg">{"Jpeg"}</option>
|
||||
<option value="DateTime">{"DateTime"}</option>
|
||||
</Select<CreateGroupAttributeModel>>
|
||||
<CheckBox<CreateGroupAttributeModel>
|
||||
label="Multiple values"
|
||||
form={&self.form}
|
||||
field_name="is_list"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<CheckBox<CreateGroupAttributeModel>
|
||||
label="Visible to users"
|
||||
form={&self.form}
|
||||
field_name="is_visible"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
-138
@@ -1,24 +1,11 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
field::Field,
|
||||
submit::Submit,
|
||||
},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
components::router::AppRoute,
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{
|
||||
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
|
||||
read_all_form_attributes,
|
||||
},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, ensure};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{opaque, registration};
|
||||
@@ -27,32 +14,6 @@ use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetUserAttributesSchema;
|
||||
|
||||
use get_user_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||
|
||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||
fn from(attr: &Attribute) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
@@ -65,14 +26,17 @@ pub struct CreateUser;
|
||||
pub struct CreateUserForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateUserModel>,
|
||||
attributes_schema: Option<Vec<Attribute>>,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct CreateUserModel {
|
||||
#[validate(length(min = 1, message = "Username is required"))]
|
||||
username: String,
|
||||
#[validate(email(message = "A valid email is required"))]
|
||||
email: String,
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
#[validate(custom(
|
||||
function = "empty_or_long",
|
||||
message = "Password should be longer than 8 characters (or left empty)"
|
||||
@@ -92,7 +56,6 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
SubmitForm,
|
||||
CreateUserResponse(Result<create_user::ResponseData>),
|
||||
SuccessfulCreation,
|
||||
@@ -113,43 +76,21 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes_schema =
|
||||
Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitForm => {
|
||||
ensure!(self.form.validate(), "Check the form for errors");
|
||||
|
||||
let all_values = read_all_form_attributes(
|
||||
self.attributes_schema.iter().flatten(),
|
||||
&self.form_ref,
|
||||
IsAdmin(true),
|
||||
EmailIsRequired(true),
|
||||
)?;
|
||||
let attributes = Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| create_user::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let model = self.form.model();
|
||||
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
|
||||
let req = create_user::Variables {
|
||||
user: create_user::CreateUserInput {
|
||||
id: model.username,
|
||||
email: None,
|
||||
displayName: None,
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
email: model.email,
|
||||
displayName: to_option(model.display_name),
|
||||
firstName: to_option(model.first_name),
|
||||
lastName: to_option(model.last_name),
|
||||
avatar: None,
|
||||
attributes,
|
||||
attributes: None,
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
@@ -233,20 +174,11 @@ impl Component for CreateUserForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||
attributes_schema: None,
|
||||
form_ref: NodeRef::default(),
|
||||
};
|
||||
component.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||
ctx,
|
||||
get_user_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch user schema",
|
||||
);
|
||||
component
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
@@ -255,41 +187,163 @@ impl Component for CreateUserForm {
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
type Field = yew_form::Field<CreateUserModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3"
|
||||
ref={self.form_ref.clone()}>
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="User name"
|
||||
field_name="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
{
|
||||
self.attributes_schema
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_readonly)
|
||||
.map(get_custom_attribute_input)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Password"
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Confirm password"
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a user"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="username"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"User name"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("username")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
input_type="email"
|
||||
field_name="email"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="email"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="display_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Display name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
autocomplete="name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="display_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="first_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"First name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
autocomplete="given-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="first_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="last_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Last name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
autocomplete="family-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="last_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="password"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Password:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
input_type="password"
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Confirm password:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form={&self.form}
|
||||
input_type="password"
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label mt-4"
|
||||
disabled={self.common.is_task_running()}
|
||||
type="submit"
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
@@ -304,21 +358,3 @@ impl Component for CreateUserForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::{AttributeType, validate_attribute_type},
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_validation::attributes::validate_attribute_name;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/create_user_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct CreateUserAttribute;
|
||||
|
||||
convert_attribute_type!(create_user_attribute::AttributeType);
|
||||
|
||||
pub struct CreateUserAttributeForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateUserAttributeModel>,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||
pub struct CreateUserAttributeModel {
|
||||
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||
attribute_name: String,
|
||||
#[validate(custom = "validate_attribute_type")]
|
||||
attribute_type: String,
|
||||
is_editable: bool,
|
||||
is_list: bool,
|
||||
is_visible: bool,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
SubmitForm,
|
||||
CreateUserAttributeResponse(Result<create_user_attribute::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let model = self.form.model();
|
||||
if model.is_editable && !model.is_visible {
|
||||
bail!("Editable attributes must also be visible");
|
||||
}
|
||||
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
|
||||
let invalid = String::from_iter(invalid_chars);
|
||||
bail!(
|
||||
"Attribute name contains one or more invalid characters: {}",
|
||||
invalid
|
||||
);
|
||||
})?;
|
||||
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||
let req = create_user_attribute::Variables {
|
||||
name: model.attribute_name,
|
||||
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
|
||||
is_editable: model.is_editable,
|
||||
is_list: model.is_list,
|
||||
is_visible: model.is_visible,
|
||||
};
|
||||
self.common.call_graphql::<CreateUserAttribute, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateUserAttributeResponse,
|
||||
"Error trying to create user attribute",
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateUserAttributeResponse(response) => {
|
||||
response?;
|
||||
let model = self.form.model();
|
||||
log!(&format!(
|
||||
"Created user attribute '{}'",
|
||||
model.attribute_name
|
||||
));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListUserSchema);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CreateUserAttributeForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
let model = CreateUserAttributeModel {
|
||||
attribute_type: AttributeType::String.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserAttributeModel>::new(model),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<h5 class="fw-bold">{"Create a user attribute"}</h5>
|
||||
<Field<CreateUserAttributeModel>
|
||||
label="Name"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Select<CreateUserAttributeModel>
|
||||
label="Type"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_type"
|
||||
oninput={link.callback(|_| Msg::Update)}>
|
||||
<option selected=true value="String">{"String"}</option>
|
||||
<option value="Integer">{"Integer"}</option>
|
||||
<option value="Jpeg">{"Jpeg"}</option>
|
||||
<option value="DateTime">{"DateTime"}</option>
|
||||
</Select<CreateUserAttributeModel>>
|
||||
<CheckBox<CreateUserAttributeModel>
|
||||
label="Multiple values"
|
||||
form={&self.form}
|
||||
field_name="is_list"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<CheckBox<CreateUserAttributeModel>
|
||||
label="Visible to users"
|
||||
form={&self.form}
|
||||
field_name="is_visible"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<CheckBox<CreateUserAttributeModel>
|
||||
label="Editable by users"
|
||||
form={&self.form}
|
||||
field_name="is_editable"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
use crate::infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/delete_group_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct DeleteGroupAttributeQuery;
|
||||
|
||||
pub struct DeleteGroupAttribute {
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
pub struct DeleteGroupAttributeProps {
|
||||
pub attribute_name: String,
|
||||
pub on_attribute_deleted: Callback<String>,
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ClickedDeleteGroupAttribute,
|
||||
ConfirmDeleteGroupAttribute,
|
||||
DismissModal,
|
||||
DeleteGroupAttributeResponse(Result<delete_group_attribute_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteGroupAttribute> for DeleteGroupAttribute {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteGroupAttribute => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroupAttribute => {
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteGroupAttributeQuery, _>(
|
||||
ctx,
|
||||
delete_group_attribute_query::Variables {
|
||||
name: ctx.props().attribute_name.clone(),
|
||||
},
|
||||
Msg::DeleteGroupAttributeResponse,
|
||||
"Error trying to delete group attribute",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupAttributeResponse(response) => {
|
||||
response?;
|
||||
ctx.props()
|
||||
.on_attribute_deleted
|
||||
.emit(ctx.props().attribute_name.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteGroupAttribute {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteGroupAttributeProps;
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
.cast::<web_sys::Element>()
|
||||
.expect("Modal node is not an element"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteGroupAttribute)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||
</button>
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteGroupAttribute {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id={"deleteGroupAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteGroupAttributeModalLabel"
|
||||
aria-hidden="true"
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteGroupAttributeModalLabel">{"Delete group attribute?"}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete group attribute "}
|
||||
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteGroupAttribute)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
use crate::infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/delete_user_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct DeleteUserAttributeQuery;
|
||||
|
||||
pub struct DeleteUserAttribute {
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
pub struct DeleteUserAttributeProps {
|
||||
pub attribute_name: String,
|
||||
pub on_attribute_deleted: Callback<String>,
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ClickedDeleteUserAttribute,
|
||||
ConfirmDeleteUserAttribute,
|
||||
DismissModal,
|
||||
DeleteUserAttributeResponse(Result<delete_user_attribute_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteUserAttribute> for DeleteUserAttribute {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteUserAttribute => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUserAttribute => {
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteUserAttributeQuery, _>(
|
||||
ctx,
|
||||
delete_user_attribute_query::Variables {
|
||||
name: ctx.props().attribute_name.clone(),
|
||||
},
|
||||
Msg::DeleteUserAttributeResponse,
|
||||
"Error trying to delete user attribute",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserAttributeResponse(response) => {
|
||||
response?;
|
||||
ctx.props()
|
||||
.on_attribute_deleted
|
||||
.emit(ctx.props().attribute_name.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteUserAttribute {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteUserAttributeProps;
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
.cast::<web_sys::Element>()
|
||||
.expect("Modal node is not an element"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteUserAttribute)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||
</button>
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteUserAttribute {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id={"deleteUserAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteUserAttributeModalLabel"
|
||||
aria-hidden="true"
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteUserAttributeModalLabel">{"Delete user attribute?"}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete user attribute "}
|
||||
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteUserAttribute)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
use crate::{
|
||||
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
|
||||
infra::{schema::AttributeType, tooltip::Tooltip},
|
||||
};
|
||||
use web_sys::Element;
|
||||
use yew::{
|
||||
Component, Context, Html, Properties, function_component, html, use_effect_with_deps,
|
||||
use_node_ref, virtual_dom::AttrValue,
|
||||
};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct AttributeInputProps {
|
||||
name: AttrValue,
|
||||
attribute_type: AttributeType,
|
||||
#[prop_or(None)]
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(AttributeInput)]
|
||||
fn attribute_input(props: &AttributeInputProps) -> Html {
|
||||
let input_type = match props.attribute_type {
|
||||
AttributeType::String => "text",
|
||||
AttributeType::Integer => "number",
|
||||
AttributeType::DateTime => {
|
||||
return html! {
|
||||
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
||||
};
|
||||
}
|
||||
AttributeType::Jpeg => {
|
||||
return html! {
|
||||
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<input
|
||||
type={input_type}
|
||||
name={props.name.clone()}
|
||||
class="form-control"
|
||||
value={props.value.clone()} />
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct AttributeLabelProps {
|
||||
pub name: String,
|
||||
}
|
||||
#[function_component(AttributeLabel)]
|
||||
fn attribute_label(props: &AttributeLabelProps) -> Html {
|
||||
let tooltip_ref = use_node_ref();
|
||||
|
||||
use_effect_with_deps(
|
||||
move |tooltip_ref| {
|
||||
Tooltip::new(
|
||||
tooltip_ref
|
||||
.cast::<Element>()
|
||||
.expect("Tooltip element should exist"),
|
||||
);
|
||||
|| {}
|
||||
},
|
||||
tooltip_ref.clone(),
|
||||
);
|
||||
|
||||
html! {
|
||||
<label for={props.name.clone()}
|
||||
class="form-label col-4 col-form-label"
|
||||
>
|
||||
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
|
||||
<button
|
||||
class="btn btn-sm btn-link"
|
||||
type="button"
|
||||
data-bs-placement="right"
|
||||
title={props.name.clone()}
|
||||
ref={tooltip_ref}>
|
||||
<i class="bi bi-info-circle" aria-label="Info" />
|
||||
</button>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SingleAttributeInputProps {
|
||||
pub name: String,
|
||||
pub attribute_type: AttributeType,
|
||||
#[prop_or(None)]
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(SingleAttributeInput)]
|
||||
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<AttributeLabel name={props.name.clone()} />
|
||||
<div class="col-8">
|
||||
<AttributeInput
|
||||
attribute_type={props.attribute_type.clone()}
|
||||
name={props.name.clone()}
|
||||
value={props.value.clone()} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ListAttributeInputProps {
|
||||
pub name: String,
|
||||
pub attribute_type: AttributeType,
|
||||
#[prop_or(vec!())]
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
pub enum ListAttributeInputMsg {
|
||||
Remove(usize),
|
||||
Append,
|
||||
}
|
||||
|
||||
pub struct ListAttributeInput {
|
||||
indices: Vec<usize>,
|
||||
next_index: usize,
|
||||
values: Vec<String>,
|
||||
}
|
||||
impl Component for ListAttributeInput {
|
||||
type Message = ListAttributeInputMsg;
|
||||
type Properties = ListAttributeInputProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let values = ctx.props().values.clone();
|
||||
Self {
|
||||
indices: (0..values.len()).collect(),
|
||||
next_index: values.len(),
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ListAttributeInputMsg::Remove(removed) => {
|
||||
self.indices.retain_mut(|x| *x != removed);
|
||||
}
|
||||
ListAttributeInputMsg::Append => {
|
||||
self.indices.push(self.next_index);
|
||||
self.next_index += 1;
|
||||
}
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||
if ctx.props().values != self.values {
|
||||
self.values.clone_from(&ctx.props().values);
|
||||
self.indices = (0..self.values.len()).collect();
|
||||
self.next_index = self.values.len();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let props = &ctx.props();
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<AttributeLabel name={props.name.clone()} />
|
||||
<div class="col-8">
|
||||
{self.indices.iter().map(|&i| html! {
|
||||
<div class="input-group mb-2" key={i}>
|
||||
<AttributeInput
|
||||
attribute_type={props.attribute_type.clone()}
|
||||
name={props.name.clone()}
|
||||
value={props.values.get(i).cloned().unwrap_or_default()} />
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
|
||||
<i class="bi-x-circle-fill" aria-label="Remove value" />
|
||||
</button>
|
||||
</div>
|
||||
}).collect::<Html>()}
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Add value"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
use yew::{Callback, Properties, function_component, html, virtual_dom::AttrValue};
|
||||
use yew_form::{Form, Model};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props<T: Model> {
|
||||
pub label: AttrValue,
|
||||
pub field_name: String,
|
||||
pub form: Form<T>,
|
||||
#[prop_or(false)]
|
||||
pub required: bool,
|
||||
#[prop_or_else(Callback::noop)]
|
||||
pub ontoggle: Callback<bool>,
|
||||
}
|
||||
|
||||
#[function_component(CheckBox)]
|
||||
pub fn checkbox<T: Model>(props: &Props<T>) -> Html {
|
||||
html! {
|
||||
<div class="form-group row mb-3">
|
||||
<label for={props.field_name.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.label}
|
||||
{if props.required {
|
||||
html!{<span class="text-danger">{"*"}</span>}
|
||||
} else {html!{}}}
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<yew_form::CheckBox<T>
|
||||
form={&props.form}
|
||||
field_name={props.field_name.clone()}
|
||||
ontoggle={props.ontoggle.clone()} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::{Event, Properties, function_component, html, use_state, virtual_dom::AttrValue};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct DateTimeInputProps {
|
||||
pub name: AttrValue,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(DateTimeInput)]
|
||||
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
|
||||
let value = use_state(|| {
|
||||
props
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="hidden"
|
||||
name={props.name.clone()}
|
||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
class="form-control"
|
||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
|
||||
onchange={move |e: Event| {
|
||||
let string_val =
|
||||
e.target()
|
||||
.expect("Event should have target")
|
||||
.unchecked_into::<HtmlInputElement>()
|
||||
.value();
|
||||
value.set(
|
||||
NaiveDateTime::from_str(&string_val)
|
||||
.ok()
|
||||
.map(|x| DateTime::from_naive_utc_and_offset(x, Utc))
|
||||
)
|
||||
}} />
|
||||
<span class="input-group-text">{"UTC"}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use yew::{Callback, InputEvent, Properties, function_component, html, virtual_dom::AttrValue};
|
||||
use yew_form::{Form, Model};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props<T: Model> {
|
||||
pub label: AttrValue,
|
||||
pub field_name: String,
|
||||
pub form: Form<T>,
|
||||
#[prop_or(false)]
|
||||
pub required: bool,
|
||||
#[prop_or(String::from("text"))]
|
||||
pub input_type: String,
|
||||
// If not present, will default to field_name
|
||||
#[prop_or(None)]
|
||||
pub autocomplete: Option<String>,
|
||||
#[prop_or_else(Callback::noop)]
|
||||
pub oninput: Callback<InputEvent>,
|
||||
}
|
||||
|
||||
#[function_component(Field)]
|
||||
pub fn field<T: Model>(props: &Props<T>) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<label for={props.field_name.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.label}
|
||||
{if props.required {
|
||||
html!{<span class="text-danger">{"*"}</span>}
|
||||
} else {html!{}}}
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<yew_form::Field<T>
|
||||
form={&props.form}
|
||||
field_name={props.field_name.clone()}
|
||||
input_type={props.input_type.clone()}
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete={props.autocomplete.clone().unwrap_or(props.field_name.clone())}
|
||||
oninput={&props.oninput} />
|
||||
<div class="invalid-feedback">
|
||||
{&props.form.field_message(&props.field_name)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use anyhow::{Error, Ok, Result, bail};
|
||||
use gloo_file::{
|
||||
File,
|
||||
callbacks::{FileReader, read_as_bytes},
|
||||
};
|
||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||
use yew::Properties;
|
||||
use yew::{prelude::*, virtual_dom::AttrValue};
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
file: Option<File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Display for JsFile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for JsFile {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
if s.is_empty() {
|
||||
Ok(JsFile::default())
|
||||
} else {
|
||||
bail!("Building file from non-empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_base64(file: &JsFile) -> Result<String> {
|
||||
match file {
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: None,
|
||||
} => Ok(String::new()),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} => bail!("Image file hasn't finished loading, try again"),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: Some(data),
|
||||
} => {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
}
|
||||
Ok(base64::encode(data))
|
||||
}
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: Some(data),
|
||||
} => Ok(base64::encode(data)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||
pub struct JpegFileInput {
|
||||
// None means that the avatar hasn't changed.
|
||||
avatar: Option<JsFile>,
|
||||
reader: Option<FileReader>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
/// A new file was selected.
|
||||
FileSelected(File),
|
||||
/// The "Clear" button for the avatar was clicked.
|
||||
ClearClicked,
|
||||
/// A picked file finished loading.
|
||||
FileLoaded(String, Result<Vec<u8>>),
|
||||
}
|
||||
|
||||
#[derive(Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub name: AttrValue,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
impl Component for JpegFileInput {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
avatar: Some(JsFile {
|
||||
file: None,
|
||||
contents: ctx
|
||||
.props()
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|x| base64::decode(x).ok()),
|
||||
}),
|
||||
reader: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||
self.avatar = Some(JsFile {
|
||||
file: None,
|
||||
contents: ctx
|
||||
.props()
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|x| base64::decode(x).ok()),
|
||||
});
|
||||
self.reader = None;
|
||||
true
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
Msg::Update => true,
|
||||
Msg::FileSelected(new_avatar) => {
|
||||
if self
|
||||
.avatar
|
||||
.as_ref()
|
||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||
!= Some(new_avatar.name())
|
||||
{
|
||||
let file_name = new_avatar.name();
|
||||
let link = ctx.link().clone();
|
||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||
link.send_message(Msg::FileLoaded(
|
||||
file_name,
|
||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||
))
|
||||
}));
|
||||
self.avatar = Some(JsFile {
|
||||
file: Some(new_avatar),
|
||||
contents: None,
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::ClearClicked => {
|
||||
self.avatar = Some(JsFile::default());
|
||||
true
|
||||
}
|
||||
Msg::FileLoaded(file_name, data) => {
|
||||
if let Some(avatar) = &mut self.avatar {
|
||||
if let Some(file) = &avatar.file {
|
||||
if file.name() == file_name {
|
||||
if let Result::Ok(data) = data {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = Some(JsFile::default());
|
||||
// TODO: bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
avatar.contents = Some(data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reader = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
|
||||
let avatar_string = match &self.avatar {
|
||||
Some(avatar) => {
|
||||
let avatar_base64 = to_base64(avatar);
|
||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
html! {
|
||||
<div class="row align-items-center">
|
||||
<div class="col-5">
|
||||
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
|
||||
<input
|
||||
class="form-control"
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
Self::upload_files(input.files())
|
||||
})} />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-secondary col-auto"
|
||||
id="avatarClear"
|
||||
type="button"
|
||||
onclick={link.callback(|_| {Msg::ClearClicked})}>
|
||||
{"Clear"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{
|
||||
if !avatar_string.is_empty() {
|
||||
html!{
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||
alt="Avatar" />
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JpegFileInput {
|
||||
fn upload_files(files: Option<FileList>) -> Msg {
|
||||
match files {
|
||||
Some(files) if files.length() > 0 => {
|
||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||
}
|
||||
Some(_) | None => Msg::Update,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||
.decode()
|
||||
.is_ok()
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
pub mod attribute_input;
|
||||
pub mod checkbox;
|
||||
pub mod date_input;
|
||||
pub mod field;
|
||||
pub mod file_input;
|
||||
pub mod select;
|
||||
pub mod static_value;
|
||||
pub mod submit;
|
||||
@@ -1,46 +0,0 @@
|
||||
use yew::{
|
||||
Callback, Children, InputEvent, Properties, function_component, html, virtual_dom::AttrValue,
|
||||
};
|
||||
use yew_form::{Form, Model};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props<T: Model> {
|
||||
pub label: AttrValue,
|
||||
pub field_name: String,
|
||||
pub form: Form<T>,
|
||||
#[prop_or(false)]
|
||||
pub required: bool,
|
||||
#[prop_or_else(Callback::noop)]
|
||||
pub oninput: Callback<InputEvent>,
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
#[function_component(Select)]
|
||||
pub fn select<T: Model>(props: &Props<T>) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<label for={props.field_name.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.label}
|
||||
{if props.required {
|
||||
html!{<span class="text-danger">{"*"}</span>}
|
||||
} else {html!{}}}
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<yew_form::Select<T>
|
||||
form={&props.form}
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name={props.field_name.clone()}
|
||||
oninput={&props.oninput} >
|
||||
{for props.children.iter()}
|
||||
</yew_form::Select<T>>
|
||||
<div class="invalid-feedback">
|
||||
{&props.form.field_message(&props.field_name)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
use yew::{Children, Properties, function_component, html, virtual_dom::AttrValue};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub label: AttrValue,
|
||||
pub id: AttrValue,
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
#[function_component(StaticValue)]
|
||||
pub fn static_value(props: &Props) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<label for={props.id.clone()}
|
||||
class="form-label col-4 col-form-label">
|
||||
{&props.label}
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id={props.id.clone()} class="form-control-static">
|
||||
{for props.children.iter()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
use web_sys::MouseEvent;
|
||||
use yew::{Callback, Children, Properties, function_component, html, virtual_dom::AttrValue};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub disabled: bool,
|
||||
pub onclick: Callback<MouseEvent>,
|
||||
// Additional elements to insert after the button, in the same div
|
||||
#[prop_or_default]
|
||||
pub children: Children,
|
||||
#[prop_or(AttrValue::from("Submit"))]
|
||||
pub text: AttrValue,
|
||||
}
|
||||
|
||||
#[function_component(Submit)]
|
||||
pub fn submit(props: &Props) -> Html {
|
||||
html! {
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled={props.disabled}
|
||||
onclick={&props.onclick}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{props.text.clone()}
|
||||
</button>
|
||||
{for props.children.iter()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
use crate::infra::attributes::AttributeDescription;
|
||||
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
|
||||
use yew::{Html, html};
|
||||
|
||||
fn render_attribute_aliases(attribute_description: &AttributeDescription) -> Html {
|
||||
if attribute_description.aliases.is_empty() {
|
||||
html! {}
|
||||
} else {
|
||||
html! {
|
||||
<>
|
||||
<br/>
|
||||
<small class="text-muted">
|
||||
{"Aliases: "}
|
||||
{attribute_description.aliases.join(", ")}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_attribute_validation_warnings(attribute_name: &str) -> Html {
|
||||
match validate_attribute_name(attribute_name) {
|
||||
Ok(()) => {
|
||||
html! {}
|
||||
}
|
||||
Err(_invalid_chars) => {
|
||||
html! {
|
||||
<>
|
||||
<br/>
|
||||
<small class="text-warning">
|
||||
{"Warning: This attribute uses one or more invalid characters "}
|
||||
{"("}{ALLOWED_CHARACTERS_DESCRIPTION}{"). "}
|
||||
{"Some clients may not support it."}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_attribute_name(
|
||||
hardcoded: bool,
|
||||
attribute_description: &AttributeDescription,
|
||||
) -> Html {
|
||||
html! {
|
||||
<>
|
||||
{&attribute_description.attribute_name}
|
||||
{if hardcoded {render_attribute_aliases(attribute_description)} else {html!{}}}
|
||||
{render_attribute_validation_warnings(attribute_description.attribute_name)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod attribute_schema;
|
||||
@@ -1,17 +1,12 @@
|
||||
use crate::{
|
||||
components::{
|
||||
add_group_member::{self, AddGroupMemberComponent},
|
||||
group_details_form::GroupDetailsForm,
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::GraphQlAttributeSchema,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result, bail};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
@@ -27,28 +22,12 @@ pub struct GetGroupDetails;
|
||||
pub type Group = get_group_details::GetGroupDetailsGroup;
|
||||
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
||||
pub type AddGroupMemberUser = add_group_member::User;
|
||||
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
|
||||
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
|
||||
pub type AttributeType = get_group_details::AttributeType;
|
||||
|
||||
convert_attribute_type!(AttributeType);
|
||||
|
||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||
fn from(attr: &AttributeSchema) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GroupDetails {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
group_and_schema: Option<(Group, Vec<AttributeSchema>)>,
|
||||
group: Option<Group>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -59,13 +38,11 @@ pub enum Msg {
|
||||
OnError(Error),
|
||||
OnUserAddedToGroup(AddGroupMemberUser),
|
||||
OnUserRemovedFromGroup((String, i64)),
|
||||
DisplayNameUpdated,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub group_id: i64,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl GroupDetails {
|
||||
@@ -92,16 +69,41 @@ impl GroupDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_details(&self, ctx: &Context<Self>, g: &Group, schema: Vec<AttributeSchema>) -> Html {
|
||||
fn view_details(&self, g: &Group) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<h3>{g.display_name.to_string()}</h3>
|
||||
<GroupDetailsForm
|
||||
group={g.clone()}
|
||||
group_attributes_schema={schema}
|
||||
is_admin={ctx.props().is_admin}
|
||||
on_display_name_updated={ctx.link().callback(|_| Msg::DisplayNameUpdated)}
|
||||
/>
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="displayName"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="uuid"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -180,38 +182,29 @@ impl GroupDetails {
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => {
|
||||
self.group_and_schema =
|
||||
Some((group.group, group.schema.group_schema.attributes))
|
||||
}
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
Err(e) => {
|
||||
self.group_and_schema = None;
|
||||
self.group = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(user) => {
|
||||
self.group_and_schema.as_mut().unwrap().0.users.push(User {
|
||||
self.group.as_mut().unwrap().users.push(User {
|
||||
id: user.id,
|
||||
display_name: user.display_name,
|
||||
});
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||
self.group_and_schema
|
||||
self.group
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.0
|
||||
.users
|
||||
.retain(|u| u.id != user_id);
|
||||
}
|
||||
Msg::DisplayNameUpdated => self.get_group_details(ctx),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -228,7 +221,7 @@ impl Component for GroupDetails {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group_and_schema: None,
|
||||
group: None,
|
||||
};
|
||||
table.get_group_details(ctx);
|
||||
table
|
||||
@@ -239,15 +232,15 @@ impl Component for GroupDetails {
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.group_and_schema, &self.common.error) {
|
||||
match (&self.group, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some((group, schema)), error) => {
|
||||
(Some(u), error) => {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_details(ctx, group, schema.clone())}
|
||||
{self.view_user_list(ctx, group)}
|
||||
{self.view_add_user_button(ctx, group)}
|
||||
{self.view_details(u)}
|
||||
{self.view_user_list(ctx, u)}
|
||||
{self.view_add_user_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
static_value::StaticValue,
|
||||
submit::Submit,
|
||||
},
|
||||
group_details::{Attribute, AttributeSchema, Group},
|
||||
},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{Ok, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
/// The GraphQL query sent to the server to update the group details.
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/update_group.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct UpdateGroup;
|
||||
|
||||
/// A [yew::Component] to display the group details, with a form allowing to edit them.
|
||||
pub struct GroupDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// True if we just successfully updated the group, to display a success message.
|
||||
just_updated: bool,
|
||||
updated_group_name: bool,
|
||||
group: Group,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
/// A form field changed.
|
||||
Update,
|
||||
/// The "Submit" button was clicked.
|
||||
SubmitClicked,
|
||||
/// We got the response from the server about our update message.
|
||||
GroupUpdated(Result<update_group::ResponseData>),
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
pub struct Props {
|
||||
/// The current group details.
|
||||
pub group: Group,
|
||||
pub group_attributes_schema: Vec<AttributeSchema>,
|
||||
pub is_admin: bool,
|
||||
pub on_display_name_updated: Callback<()>,
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetailsForm> for GroupDetailsForm {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_group_update_form(ctx),
|
||||
Msg::GroupUpdated(Err(e)) => Err(e),
|
||||
Msg::GroupUpdated(Result::Ok(_)) => {
|
||||
self.just_updated = true;
|
||||
if self.updated_group_name {
|
||||
self.updated_group_name = false;
|
||||
ctx.props().on_display_name_updated.emit(());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GroupDetailsForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
just_updated: false,
|
||||
updated_group_name: false,
|
||||
group: ctx.props().group.clone(),
|
||||
form_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
self.just_updated = false;
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
|
||||
let can_edit =
|
||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||
let display_field = |a: &AttributeSchema| {
|
||||
if can_edit(a) {
|
||||
get_custom_attribute_input(a, &self.group.attributes)
|
||||
} else {
|
||||
get_custom_attribute_static(a, &self.group.attributes)
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form
|
||||
class="form"
|
||||
ref={self.form_ref.clone()}>
|
||||
<StaticValue label="Group ID" id="groupId">
|
||||
<i>{&self.group.id}</i>
|
||||
</StaticValue>
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.group_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded && a.name != "group_id")
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.group_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| !a.is_hardcoded)
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
text="Save changes"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div hidden={!self.just_updated}>
|
||||
<div class="alert alert-success mt-4">{"Group successfully updated!"}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(
|
||||
attribute_schema: &AttributeSchema,
|
||||
group_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = group_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
values={values}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
value={values.first().cloned().unwrap_or_default()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_static(
|
||||
attribute_schema: &AttributeSchema,
|
||||
group_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = group_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
html! {
|
||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||
</StaticValue>
|
||||
}
|
||||
}
|
||||
|
||||
impl GroupDetailsForm {
|
||||
fn submit_group_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
let mut all_values = read_all_form_attributes(
|
||||
ctx.props().group_attributes_schema.iter(),
|
||||
&self.form_ref,
|
||||
IsAdmin(ctx.props().is_admin),
|
||||
EmailIsRequired(false),
|
||||
)?;
|
||||
let base_attributes = &self.group.attributes;
|
||||
all_values.retain(|a| {
|
||||
let base_val = base_attributes
|
||||
.iter()
|
||||
.find(|base_val| base_val.name == a.name);
|
||||
base_val
|
||||
.map(|v| v.value != a.values)
|
||||
.unwrap_or(!a.values.is_empty())
|
||||
});
|
||||
if all_values.iter().any(|a| a.name == "display_name") {
|
||||
self.updated_group_name = true;
|
||||
}
|
||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||
};
|
||||
let insert_attributes: Option<Vec<update_group::AttributeValueInput>> =
|
||||
if remove_attributes.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| update_group::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let mut group_input = update_group::UpdateGroupInput {
|
||||
id: self.group.id,
|
||||
displayName: None,
|
||||
removeAttributes: None,
|
||||
insertAttributes: None,
|
||||
};
|
||||
let default_group_input = group_input.clone();
|
||||
group_input.removeAttributes = remove_attributes;
|
||||
group_input.insertAttributes = insert_attributes;
|
||||
// Nothing changed.
|
||||
if group_input == default_group_input {
|
||||
return Ok(false);
|
||||
}
|
||||
let req = update_group::Variables { group: group_input };
|
||||
self.common.call_graphql::<UpdateGroup, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::GroupUpdated,
|
||||
"Error trying to update group",
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
use crate::{
|
||||
components::{
|
||||
delete_group_attribute::DeleteGroupAttribute,
|
||||
fragments::attribute_schema::render_attribute_name,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
attributes::group,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{Error, Result, anyhow};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_group_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetGroupAttributesSchema;
|
||||
|
||||
use get_group_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute =
|
||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub hardcoded: bool,
|
||||
}
|
||||
|
||||
pub struct GroupSchemaTable {
|
||||
common: CommonComponentParts<Self>,
|
||||
attributes: Option<Vec<Attribute>>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
OnAttributeDeleted(String),
|
||||
OnError(Error),
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupSchemaTable> for GroupSchemaTable {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes =
|
||||
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::OnError(e) => Err(e),
|
||||
Msg::OnAttributeDeleted(attribute_name) => match self.attributes {
|
||||
None => {
|
||||
log!(format!(
|
||||
"Attribute {attribute_name} was deleted but component has no attributes"
|
||||
));
|
||||
Err(anyhow!("invalid state"))
|
||||
}
|
||||
Some(_) => {
|
||||
self.attributes
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.retain(|a| a.name != attribute_name);
|
||||
Ok(true)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GroupSchemaTable {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = GroupSchemaTable {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
attributes: None,
|
||||
};
|
||||
table.common.call_graphql::<GetGroupAttributesSchema, _>(
|
||||
ctx,
|
||||
get_group_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch group schema",
|
||||
);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_attributes(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GroupSchemaTable {
|
||||
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||
let hardcoded = ctx.props().hardcoded;
|
||||
let make_table = |attributes: &Vec<Attribute>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Attribute name"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Visible"}</th>
|
||||
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
match &self.attributes {
|
||||
None => html! {{"Loading..."}},
|
||||
Some(attributes) => {
|
||||
let mut attributes = attributes.clone();
|
||||
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||
make_table(&attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||
let link = ctx.link();
|
||||
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||
let checkmark = html! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||
</svg>
|
||||
};
|
||||
let hardcoded = ctx.props().hardcoded;
|
||||
let desc = group::resolve_group_attribute_description_or_default(&attribute.name);
|
||||
html! {
|
||||
<tr key={attribute.name.clone()}>
|
||||
<td>{render_attribute_name(hardcoded, &desc)}</td>
|
||||
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||
{
|
||||
if hardcoded {
|
||||
html!{}
|
||||
} else {
|
||||
html!{
|
||||
<td>
|
||||
<DeleteGroupAttribute
|
||||
attribute_name={attribute.name.clone()}
|
||||
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_errors(&self) -> Html {
|
||||
match &self.common.error {
|
||||
None => html! {},
|
||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(ListGroupSchema)]
|
||||
pub fn list_group_schema() -> Html {
|
||||
html! {
|
||||
<div>
|
||||
<GroupSchemaTable hardcoded={true} />
|
||||
<GroupSchemaTable hardcoded={false} />
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateGroupAttribute}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Create an attribute"}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+62
-59
@@ -1,14 +1,11 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::submit::Submit,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
@@ -158,62 +155,68 @@ impl Component for LoginForm {
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<form class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-box-arrow-in-right me-2"/>
|
||||
{"Login"}
|
||||
</button>
|
||||
{ if password_reset_enabled {
|
||||
html! {
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::StartResetPassword}>
|
||||
{"Forgot your password?"}
|
||||
</Link>
|
||||
}
|
||||
} else {
|
||||
html!{}
|
||||
}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<Submit
|
||||
text="Login"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
{ if password_reset_enabled {
|
||||
html! {
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::StartResetPassword}>
|
||||
{"Forgot your password?"}
|
||||
</Link>
|
||||
}
|
||||
} else {
|
||||
html!{}
|
||||
}}
|
||||
</Submit>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
pub mod add_group_member;
|
||||
pub mod add_user_to_group;
|
||||
pub mod app;
|
||||
pub mod avatar;
|
||||
pub mod banner;
|
||||
pub mod change_password;
|
||||
pub mod create_group;
|
||||
pub mod create_group_attribute;
|
||||
pub mod create_user;
|
||||
pub mod create_user_attribute;
|
||||
pub mod delete_group;
|
||||
pub mod delete_group_attribute;
|
||||
pub mod delete_user;
|
||||
pub mod delete_user_attribute;
|
||||
pub mod form;
|
||||
pub mod fragments;
|
||||
pub mod group_details;
|
||||
pub mod group_details_form;
|
||||
pub mod group_schema_table;
|
||||
pub mod group_table;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
@@ -27,5 +17,4 @@ pub mod router;
|
||||
pub mod select;
|
||||
pub mod user_details;
|
||||
pub mod user_details_form;
|
||||
pub mod user_schema_table;
|
||||
pub mod user_table;
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::{bail, Result};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
@@ -104,11 +104,7 @@ impl Component for ResetPasswordStep1Form {
|
||||
</div>
|
||||
{ if self.just_succeeded {
|
||||
html! {
|
||||
{"If a user with this username or email exists, a password reset email will \
|
||||
be sent to the associated email address. Please check your email and \
|
||||
follow the instructions. If you don't receive an email, please check \
|
||||
your spam folder. If you still don't receive an email, please contact \
|
||||
your administrator."}
|
||||
{"A reset token has been sent to your email."}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::{bail, Result};
|
||||
use lldap_auth::{
|
||||
opaque::client::registration as opaque_registration,
|
||||
password_reset::ServerPasswordResetResponse, registration,
|
||||
@@ -148,7 +145,7 @@ impl Component for ResetPasswordStep2Form {
|
||||
(None, None) => {
|
||||
return html! {
|
||||
{"Validating token"}
|
||||
};
|
||||
}
|
||||
}
|
||||
(None, Some(e)) => {
|
||||
return html! {
|
||||
@@ -163,33 +160,65 @@ impl Component for ResetPasswordStep2Form {
|
||||
{"Back"}
|
||||
</Link>
|
||||
</>
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
<h2>{"Reset your password"}</h2>
|
||||
<form class="form">
|
||||
<Field<FormModel>
|
||||
label="New password"
|
||||
required=true
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Field<FormModel>
|
||||
label="Confirm password"
|
||||
required=true
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
|
||||
<form
|
||||
class="form">
|
||||
<div class="form-group row">
|
||||
<label for="new_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"New password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Confirm password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mt-2">
|
||||
<button
|
||||
class="btn btn-primary col-sm-1 col-form-label"
|
||||
type="submit"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
|
||||
@@ -22,14 +22,6 @@ pub enum AppRoute {
|
||||
ListGroups,
|
||||
#[at("/group/:group_id")]
|
||||
GroupDetails { group_id: i64 },
|
||||
#[at("/user-attributes")]
|
||||
ListUserSchema,
|
||||
#[at("/user-attributes/create")]
|
||||
CreateUserAttribute,
|
||||
#[at("/group-attributes")]
|
||||
ListGroupSchema,
|
||||
#[at("/group-attributes/create")]
|
||||
CreateGroupAttribute,
|
||||
#[at("/")]
|
||||
Index,
|
||||
}
|
||||
|
||||
@@ -5,13 +5,9 @@ use crate::{
|
||||
router::{AppRoute, Link},
|
||||
user_details_form::UserDetailsForm,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::GraphQlAttributeSchema,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result, bail};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
@@ -26,34 +22,12 @@ pub struct GetUserDetails;
|
||||
|
||||
pub type User = get_user_details::GetUserDetailsUser;
|
||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
||||
pub type AttributeType = get_user_details::AttributeType;
|
||||
|
||||
convert_attribute_type!(AttributeType);
|
||||
|
||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||
fn from(attr: &AttributeSchema) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UserDetails {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
user_and_schema: Option<(User, Vec<AttributeSchema>)>,
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn mut_groups(&mut self) -> &mut Vec<Group> {
|
||||
&mut self.user_and_schema.as_mut().unwrap().0.groups
|
||||
}
|
||||
user: Option<User>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -76,20 +50,22 @@ impl CommonComponent<UserDetails> for UserDetails {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserDetailsResponse(response) => match response {
|
||||
Ok(user) => {
|
||||
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
|
||||
}
|
||||
Ok(user) => self.user = Some(user.user),
|
||||
Err(e) => {
|
||||
self.user_and_schema = None;
|
||||
self.user = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(group) => {
|
||||
self.mut_groups().push(group);
|
||||
self.user.as_mut().unwrap().groups.push(group);
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
||||
self.mut_groups().retain(|g| g.id != group_id);
|
||||
self.user
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.groups
|
||||
.retain(|g| g.id != group_id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -202,7 +178,7 @@ impl Component for UserDetails {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user_and_schema: None,
|
||||
user: None,
|
||||
};
|
||||
table.get_user_details(ctx);
|
||||
table
|
||||
@@ -213,8 +189,10 @@ impl Component for UserDetails {
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.user_and_schema, &self.common.error) {
|
||||
(Some((u, schema)), error) => {
|
||||
match (&self.user, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some(u), error) => {
|
||||
html! {
|
||||
<>
|
||||
<h3>{u.id.to_string()}</h3>
|
||||
@@ -229,20 +207,13 @@ impl Component for UserDetails {
|
||||
<div>
|
||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||
</div>
|
||||
<UserDetailsForm
|
||||
user={u.clone()}
|
||||
user_attributes_schema={schema.clone()}
|
||||
is_admin={ctx.props().is_admin}
|
||||
is_edited_user_admin={u.groups.iter().any(|g| g.display_name == "lldap_admin")}
|
||||
/>
|
||||
<UserDetailsForm user={u.clone()} />
|
||||
{self.view_group_memberships(ctx, u)}
|
||||
{self.view_add_group_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
static_value::StaticValue,
|
||||
submit::Submit,
|
||||
},
|
||||
user_details::{Attribute, AttributeSchema, User},
|
||||
},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
|
||||
schema::AttributeType,
|
||||
},
|
||||
components::user_details::User,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use gloo_file::{
|
||||
callbacks::{read_as_bytes, FileReader},
|
||||
File,
|
||||
};
|
||||
use anyhow::{Ok, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
file: Option<File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ToString for JsFile {
|
||||
fn to_string(&self) -> String {
|
||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for JsFile {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
if s.is_empty() {
|
||||
Ok(JsFile::default())
|
||||
} else {
|
||||
bail!("Building file from non-empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of the form, with the editable details and the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
||||
pub struct UserModel {
|
||||
#[validate(email)]
|
||||
email: String,
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
}
|
||||
|
||||
/// The GraphQL query sent to the server to update the user details.
|
||||
#[derive(GraphQLQuery)]
|
||||
@@ -31,17 +63,26 @@ pub struct UpdateUser;
|
||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||
pub struct UserDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<UserModel>,
|
||||
// None means that the avatar hasn't changed.
|
||||
avatar: Option<JsFile>,
|
||||
reader: Option<FileReader>,
|
||||
/// True if we just successfully updated the user, to display a success message.
|
||||
just_updated: bool,
|
||||
user: User,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
/// A form field changed.
|
||||
Update,
|
||||
/// A new file was selected.
|
||||
FileSelected(File),
|
||||
/// The "Submit" button was clicked.
|
||||
SubmitClicked,
|
||||
/// The "Clear" button for the avatar was clicked.
|
||||
ClearAvatarClicked,
|
||||
/// A picked file finished loading.
|
||||
FileLoaded(String, Result<Vec<u8>>),
|
||||
/// We got the response from the server about our update message.
|
||||
UserUpdated(Result<update_user::ResponseData>),
|
||||
}
|
||||
@@ -50,9 +91,6 @@ pub enum Msg {
|
||||
pub struct Props {
|
||||
/// The current user details.
|
||||
pub user: User,
|
||||
pub user_attributes_schema: Vec<AttributeSchema>,
|
||||
pub is_admin: bool,
|
||||
pub is_edited_user_admin: bool,
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
@@ -63,12 +101,53 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||
Msg::UserUpdated(Err(e)) => Err(e),
|
||||
Msg::UserUpdated(Result::Ok(_)) => {
|
||||
self.just_updated = true;
|
||||
Msg::FileSelected(new_avatar) => {
|
||||
if self
|
||||
.avatar
|
||||
.as_ref()
|
||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||
!= Some(new_avatar.name())
|
||||
{
|
||||
let file_name = new_avatar.name();
|
||||
let link = ctx.link().clone();
|
||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||
link.send_message(Msg::FileLoaded(
|
||||
file_name,
|
||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||
))
|
||||
}));
|
||||
self.avatar = Some(JsFile {
|
||||
file: Some(new_avatar),
|
||||
contents: None,
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||
Msg::ClearAvatarClicked => {
|
||||
self.avatar = Some(JsFile::default());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||
Msg::FileLoaded(file_name, data) => {
|
||||
if let Some(avatar) = &mut self.avatar {
|
||||
if let Some(file) = &avatar.file {
|
||||
if file.name() == file_name {
|
||||
let data = data?;
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = None;
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
avatar.contents = Some(data);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reader = None;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +161,19 @@ impl Component for UserDetailsForm {
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let model = UserModel {
|
||||
email: ctx.props().user.email.clone(),
|
||||
display_name: ctx.props().user.display_name.clone(),
|
||||
first_name: ctx.props().user.first_name.clone(),
|
||||
last_name: ctx.props().user.last_name.clone(),
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::new(model),
|
||||
avatar: None,
|
||||
just_updated: false,
|
||||
reader: None,
|
||||
user: ctx.props().user.clone(),
|
||||
form_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,47 +183,173 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<UserModel>;
|
||||
let link = &ctx.link();
|
||||
|
||||
let can_edit =
|
||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||
let display_field = |a: &AttributeSchema| {
|
||||
if can_edit(a) {
|
||||
get_custom_attribute_input(a, &self.user.attributes)
|
||||
} else {
|
||||
get_custom_attribute_static(a, &self.user.attributes)
|
||||
let avatar_string = match &self.avatar {
|
||||
Some(avatar) => {
|
||||
let avatar_base64 = to_base64(avatar);
|
||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||
}
|
||||
None => self.user.avatar.as_deref().unwrap_or("").to_owned(),
|
||||
};
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form
|
||||
class="form"
|
||||
ref={self.form_ref.clone()}>
|
||||
<StaticValue label="User ID" id="userId">
|
||||
<i>{&self.user.id}</i>
|
||||
</StaticValue>
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.user_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded && a.name != "user_id")
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.user_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| !a.is_hardcoded)
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
text="Save changes"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||
<form class="form">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="userId"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"User ID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="userId" class="form-control-static"><i>{&self.user.id}</i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="uuid"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="email"
|
||||
autocomplete="email"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="display_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Display Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="first_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"First Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form={&self.form}
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="last_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Last Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form={&self.form}
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row align-items-center mb-3">
|
||||
<label for="avatar"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Avatar: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-5">
|
||||
<input
|
||||
class="form-control"
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
Self::upload_files(input.files())
|
||||
})} />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-secondary col-auto"
|
||||
id="avatarClear"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
|
||||
{"Clear"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{
|
||||
if !avatar_string.is_empty() {
|
||||
html!{
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||
alt="Avatar" />
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
@@ -155,97 +368,19 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(
|
||||
attribute_schema: &AttributeSchema,
|
||||
user_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = user_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
values={values}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
value={values.first().cloned().unwrap_or_default()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_static(
|
||||
attribute_schema: &AttributeSchema,
|
||||
user_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = user_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
html! {
|
||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||
</StaticValue>
|
||||
}
|
||||
}
|
||||
|
||||
impl UserDetailsForm {
|
||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
// TODO: Handle unloaded files.
|
||||
// if let Some(JsFile {
|
||||
// file: Some(_),
|
||||
// contents: None,
|
||||
// }) = &self.avatar
|
||||
// {
|
||||
// bail!("Image file hasn't finished loading, try again");
|
||||
// }
|
||||
let mut all_values = read_all_form_attributes(
|
||||
ctx.props().user_attributes_schema.iter(),
|
||||
&self.form_ref,
|
||||
IsAdmin(ctx.props().is_admin),
|
||||
EmailIsRequired(!ctx.props().is_edited_user_admin),
|
||||
)?;
|
||||
let base_attributes = &self.user.attributes;
|
||||
all_values.retain(|a| {
|
||||
let base_val = base_attributes
|
||||
.iter()
|
||||
.find(|base_val| base_val.name == a.name);
|
||||
base_val
|
||||
.map(|v| v.value != a.values)
|
||||
.unwrap_or(!a.values.is_empty())
|
||||
});
|
||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||
};
|
||||
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
|
||||
if remove_attributes.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| update_user::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
if !self.form.validate() {
|
||||
bail!("Invalid inputs");
|
||||
}
|
||||
if let Some(JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
}) = &self.avatar
|
||||
{
|
||||
bail!("Image file hasn't finished loading, try again");
|
||||
}
|
||||
let base_user = &self.user;
|
||||
let mut user_input = update_user::UpdateUserInput {
|
||||
id: self.user.id.clone(),
|
||||
email: None,
|
||||
@@ -257,8 +392,23 @@ impl UserDetailsForm {
|
||||
insertAttributes: None,
|
||||
};
|
||||
let default_user_input = user_input.clone();
|
||||
user_input.removeAttributes = remove_attributes;
|
||||
user_input.insertAttributes = insert_attributes;
|
||||
let model = self.form.model();
|
||||
let email = model.email;
|
||||
if base_user.email != email {
|
||||
user_input.email = Some(email);
|
||||
}
|
||||
if base_user.display_name != model.display_name {
|
||||
user_input.displayName = Some(model.display_name);
|
||||
}
|
||||
if base_user.first_name != model.first_name {
|
||||
user_input.firstName = Some(model.first_name);
|
||||
}
|
||||
if base_user.last_name != model.last_name {
|
||||
user_input.lastName = Some(model.last_name);
|
||||
}
|
||||
if let Some(avatar) = &self.avatar {
|
||||
user_input.avatar = Some(to_base64(avatar)?);
|
||||
}
|
||||
// Nothing changed.
|
||||
if user_input == default_user_input {
|
||||
return Ok(false);
|
||||
@@ -272,4 +422,58 @@ impl UserDetailsForm {
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||
r?;
|
||||
let model = self.form.model();
|
||||
self.user.email = model.email;
|
||||
self.user.display_name = model.display_name;
|
||||
self.user.first_name = model.first_name;
|
||||
self.user.last_name = model.last_name;
|
||||
if let Some(avatar) = &self.avatar {
|
||||
self.user.avatar = Some(to_base64(avatar)?);
|
||||
}
|
||||
self.just_updated = true;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn upload_files(files: Option<FileList>) -> Msg {
|
||||
if let Some(files) = files {
|
||||
if files.length() > 0 {
|
||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||
} else {
|
||||
Msg::Update
|
||||
}
|
||||
} else {
|
||||
Msg::Update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||
.decode()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn to_base64(file: &JsFile) -> Result<String> {
|
||||
match file {
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: _,
|
||||
} => Ok(String::new()),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} => bail!("Image file hasn't finished loading, try again"),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: Some(data),
|
||||
} => {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
}
|
||||
Ok(base64::encode(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
use crate::{
|
||||
components::{
|
||||
delete_user_attribute::DeleteUserAttribute,
|
||||
fragments::attribute_schema::render_attribute_name,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
attributes::user,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{Error, Result, anyhow};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetUserAttributesSchema;
|
||||
|
||||
use get_user_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub hardcoded: bool,
|
||||
}
|
||||
|
||||
pub struct UserSchemaTable {
|
||||
common: CommonComponentParts<Self>,
|
||||
attributes: Option<Vec<Attribute>>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
OnAttributeDeleted(String),
|
||||
OnError(Error),
|
||||
}
|
||||
|
||||
impl CommonComponent<UserSchemaTable> for UserSchemaTable {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes = Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::OnError(e) => Err(e),
|
||||
Msg::OnAttributeDeleted(attribute_name) => match self.attributes {
|
||||
None => {
|
||||
log!(format!(
|
||||
"Attribute {attribute_name} was deleted but component has no attributes"
|
||||
));
|
||||
Err(anyhow!("invalid state"))
|
||||
}
|
||||
Some(_) => {
|
||||
self.attributes
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.retain(|a| a.name != attribute_name);
|
||||
Ok(true)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for UserSchemaTable {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = UserSchemaTable {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
attributes: None,
|
||||
};
|
||||
table.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||
ctx,
|
||||
get_user_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch user schema",
|
||||
);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_attributes(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserSchemaTable {
|
||||
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||
let hardcoded = ctx.props().hardcoded;
|
||||
let make_table = |attributes: &Vec<Attribute>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Attribute name"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Editable"}</th>
|
||||
<th>{"Visible"}</th>
|
||||
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
match &self.attributes {
|
||||
None => html! {{"Loading..."}},
|
||||
Some(attributes) => {
|
||||
let mut attributes = attributes.clone();
|
||||
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||
make_table(&attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||
let link = ctx.link();
|
||||
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||
let checkmark = html! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||
</svg>
|
||||
};
|
||||
let hardcoded = ctx.props().hardcoded;
|
||||
let desc = user::resolve_user_attribute_description_or_default(&attribute.name);
|
||||
html! {
|
||||
<tr key={attribute.name.clone()}>
|
||||
<td>{render_attribute_name(hardcoded, &desc)}</td>
|
||||
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
|
||||
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||
{
|
||||
if hardcoded {
|
||||
html!{}
|
||||
} else {
|
||||
html!{
|
||||
<td>
|
||||
<DeleteUserAttribute
|
||||
attribute_name={attribute.name.clone()}
|
||||
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_errors(&self) -> Html {
|
||||
match &self.common.error {
|
||||
None => html! {},
|
||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(ListUserSchema)]
|
||||
pub fn list_user_schema() -> Html {
|
||||
html! {
|
||||
<div>
|
||||
<UserSchemaTable hardcoded={true} />
|
||||
<UserSchemaTable hardcoded={false} />
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateUserAttribute}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Create an attribute"}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+35
-41
@@ -1,11 +1,10 @@
|
||||
use super::cookies::set_cookie;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use gloo_net::http::{Method, RequestBuilder};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gloo_net::http::{Method, Request};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{JWTClaims, login, registration};
|
||||
use lldap_auth::{login, registration, JWTClaims};
|
||||
|
||||
use lldap_frontend_options::Options;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use web_sys::RequestCredentials;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -17,32 +16,25 @@ fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||
Ok(token.claims().clone())
|
||||
}
|
||||
|
||||
enum RequestType<Body: Serialize> {
|
||||
Get,
|
||||
Post(Body),
|
||||
}
|
||||
|
||||
const GET_REQUEST: RequestType<()> = RequestType::Get;
|
||||
const NO_BODY: Option<()> = None;
|
||||
|
||||
fn base_url() -> String {
|
||||
yew_router::utils::base_url().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn call_server<Body: Serialize>(
|
||||
async fn call_server(
|
||||
url: &str,
|
||||
body: RequestType<Body>,
|
||||
body: Option<impl Serialize>,
|
||||
error_message: &'static str,
|
||||
) -> Result<String> {
|
||||
let request_builder = RequestBuilder::new(url)
|
||||
let mut request = Request::new(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.credentials(RequestCredentials::SameOrigin);
|
||||
let request = if let RequestType::Post(b) = body {
|
||||
request_builder
|
||||
.method(Method::POST)
|
||||
.body(serde_json::to_string(&b)?)?
|
||||
} else {
|
||||
request_builder.build()?
|
||||
};
|
||||
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?)
|
||||
@@ -59,7 +51,7 @@ async fn call_server<Body: Serialize>(
|
||||
|
||||
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||
url: &str,
|
||||
request: RequestType<Body>,
|
||||
request: Option<Body>,
|
||||
error_message: &'static str,
|
||||
) -> Result<CallbackResult>
|
||||
where
|
||||
@@ -71,7 +63,7 @@ where
|
||||
|
||||
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||
url: &str,
|
||||
request: RequestType<Body>,
|
||||
request: Option<Body>,
|
||||
error_message: &'static str,
|
||||
) -> Result<()> {
|
||||
call_server(url, request, error_message).await.map(|_| ())
|
||||
@@ -110,7 +102,7 @@ impl HostService {
|
||||
let request_body = QueryType::build_query(variables);
|
||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||
&(base_url() + "/api/graphql"),
|
||||
RequestType::Post(request_body),
|
||||
Some(request_body),
|
||||
error_message,
|
||||
)
|
||||
.await
|
||||
@@ -122,7 +114,7 @@ impl HostService {
|
||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
&(base_url() + "/auth/opaque/login/start"),
|
||||
RequestType::Post(request),
|
||||
Some(request),
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
.await
|
||||
@@ -131,28 +123,19 @@ impl HostService {
|
||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
&(base_url() + "/auth/opaque/login/finish"),
|
||||
RequestType::Post(request),
|
||||
Some(request),
|
||||
"Could not finish authentication",
|
||||
)
|
||||
.await
|
||||
.and_then(set_cookies_from_jwt)
|
||||
}
|
||||
|
||||
pub async fn get_settings() -> Result<Options> {
|
||||
call_server_json_with_error_message::<Options, _>(
|
||||
&(base_url() + "/settings"),
|
||||
GET_REQUEST,
|
||||
"Could not fetch settings: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn register_start(
|
||||
request: registration::ClientRegistrationStartRequest,
|
||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
&(base_url() + "/auth/opaque/register/start"),
|
||||
RequestType::Post(request),
|
||||
Some(request),
|
||||
"Could not start registration: ",
|
||||
)
|
||||
.await
|
||||
@@ -163,7 +146,7 @@ impl HostService {
|
||||
) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&(base_url() + "/auth/opaque/register/finish"),
|
||||
RequestType::Post(request),
|
||||
Some(request),
|
||||
"Could not finish registration",
|
||||
)
|
||||
.await
|
||||
@@ -172,7 +155,7 @@ impl HostService {
|
||||
pub async fn refresh() -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
&(base_url() + "/auth/refresh"),
|
||||
GET_REQUEST,
|
||||
NO_BODY,
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
.await
|
||||
@@ -183,7 +166,7 @@ impl HostService {
|
||||
pub async fn logout() -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&(base_url() + "/auth/logout"),
|
||||
GET_REQUEST,
|
||||
NO_BODY,
|
||||
"Could not logout",
|
||||
)
|
||||
.await
|
||||
@@ -196,7 +179,7 @@ impl HostService {
|
||||
base_url(),
|
||||
url_escape::encode_query(&username)
|
||||
),
|
||||
RequestType::Post(""),
|
||||
NO_BODY,
|
||||
"Could not initiate password reset",
|
||||
)
|
||||
.await
|
||||
@@ -207,9 +190,20 @@ impl HostService {
|
||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
||||
GET_REQUEST,
|
||||
NO_BODY,
|
||||
"Could not validate token",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn probe_password_reset() -> Result<bool> {
|
||||
Ok(gloo_net::http::Request::get(
|
||||
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
|
||||
)
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?
|
||||
.status()
|
||||
!= http::StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
pub struct AttributeDescription<'a> {
|
||||
pub attribute_identifier: &'a str,
|
||||
pub attribute_name: &'a str,
|
||||
pub aliases: Vec<&'a str>,
|
||||
}
|
||||
|
||||
pub mod group {
|
||||
|
||||
use super::AttributeDescription;
|
||||
|
||||
pub fn resolve_group_attribute_description(name: &str) -> Option<AttributeDescription> {
|
||||
match name {
|
||||
"creation_date" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "creationdate",
|
||||
aliases: vec![name, "createtimestamp", "modifytimestamp"],
|
||||
}),
|
||||
"display_name" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "displayname",
|
||||
aliases: vec![name, "cn", "uid", "id"],
|
||||
}),
|
||||
"group_id" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "groupid",
|
||||
aliases: vec![name],
|
||||
}),
|
||||
"uuid" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec!["entryuuid"],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_group_attribute_description_or_default(name: &str) -> AttributeDescription {
|
||||
match resolve_group_attribute_description(name) {
|
||||
Some(d) => d,
|
||||
None => AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod user {
|
||||
|
||||
use super::AttributeDescription;
|
||||
|
||||
pub fn resolve_user_attribute_description(name: &str) -> Option<AttributeDescription> {
|
||||
match name {
|
||||
"avatar" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec!["jpegphoto"],
|
||||
}),
|
||||
"creation_date" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "creationdate",
|
||||
aliases: vec![name, "createtimestamp", "modifytimestamp"],
|
||||
}),
|
||||
"display_name" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "displayname",
|
||||
aliases: vec![name, "cn"],
|
||||
}),
|
||||
"first_name" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "firstname",
|
||||
aliases: vec![name, "givenname"],
|
||||
}),
|
||||
"last_name" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "lastname",
|
||||
aliases: vec![name, "sn"],
|
||||
}),
|
||||
"mail" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec!["email"],
|
||||
}),
|
||||
"user_id" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "uid",
|
||||
aliases: vec![name, "id"],
|
||||
}),
|
||||
"uuid" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec!["entryuuid"],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_user_attribute_description_or_default(name: &str) -> AttributeDescription {
|
||||
match resolve_user_attribute_description(name) {
|
||||
Some(d) => d,
|
||||
None => AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlDocument;
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
use anyhow::{Result, anyhow, ensure};
|
||||
use validator::validate_email;
|
||||
use web_sys::{FormData, HtmlFormElement};
|
||||
use yew::NodeRef;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AttributeValue {
|
||||
pub name: String,
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct GraphQlAttributeSchema {
|
||||
pub name: String,
|
||||
pub is_list: bool,
|
||||
pub is_readonly: bool,
|
||||
pub is_editable: bool,
|
||||
}
|
||||
|
||||
fn validate_email_attributes(all_values: &[AttributeValue]) -> Result<()> {
|
||||
let maybe_email_values = all_values.iter().find(|a| a.name == "mail");
|
||||
let email_values = &maybe_email_values
|
||||
.ok_or_else(|| anyhow!("Email is required"))?
|
||||
.values;
|
||||
ensure!(!email_values.is_empty(), "Email is required");
|
||||
ensure!(email_values.len() == 1, "Multiple emails are not supported");
|
||||
ensure!(validate_email(&email_values[0]), "Email is not valid");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct IsAdmin(pub bool);
|
||||
pub struct EmailIsRequired(pub bool);
|
||||
|
||||
pub fn read_all_form_attributes(
|
||||
schema: impl IntoIterator<Item = impl Into<GraphQlAttributeSchema>>,
|
||||
form_ref: &NodeRef,
|
||||
is_admin: IsAdmin,
|
||||
email_is_required: EmailIsRequired,
|
||||
) -> Result<Vec<AttributeValue>> {
|
||||
let form = form_ref.cast::<HtmlFormElement>().unwrap();
|
||||
let form_data = FormData::new_with_form(&form)
|
||||
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
||||
let all_values = schema
|
||||
.into_iter()
|
||||
.map(Into::<GraphQlAttributeSchema>::into)
|
||||
.filter(|attr| !attr.is_readonly && (is_admin.0 || attr.is_editable))
|
||||
.map(|attr| -> Result<AttributeValue> {
|
||||
let val = form_data
|
||||
.get_all(attr.name.as_str())
|
||||
.iter()
|
||||
.map(|js_val| js_val.as_string().unwrap_or_default())
|
||||
.filter(|val| !val.is_empty())
|
||||
.collect::<Vec<String>>();
|
||||
ensure!(
|
||||
val.len() <= 1 || attr.is_list,
|
||||
"Multiple values supplied for non-list attribute {}",
|
||||
attr.name
|
||||
);
|
||||
Ok(AttributeValue {
|
||||
name: attr.name.clone(),
|
||||
values: val,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if email_is_required.0 {
|
||||
validate_email_attributes(&all_values)?;
|
||||
}
|
||||
Ok(all_values)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
use crate::infra::api::HostService;
|
||||
use anyhow::Result;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::{UseStateHandle, use_effect_with_deps, use_state_eq};
|
||||
|
||||
// Enum to represent a result that is fetched asynchronously.
|
||||
#[derive(Debug)]
|
||||
pub enum LoadableResult<T> {
|
||||
// The result is still being fetched
|
||||
Loading,
|
||||
// The async call is completed
|
||||
Loaded(Result<T>),
|
||||
}
|
||||
|
||||
impl<T: PartialEq> PartialEq for LoadableResult<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(LoadableResult::Loading, LoadableResult::Loading) => true,
|
||||
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
|
||||
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
|
||||
e1.to_string().eq(&e2.to_string())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_graphql_call<QueryType>(
|
||||
variables: QueryType::Variables,
|
||||
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
||||
where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
|
||||
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
||||
{
|
||||
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||
use_state_eq(|| LoadableResult::Loading);
|
||||
{
|
||||
let loadable_result = loadable_result.clone();
|
||||
use_effect_with_deps(
|
||||
move |variables| {
|
||||
let task = HostService::graphql_query::<QueryType>(
|
||||
variables.clone(),
|
||||
"Failed graphql query",
|
||||
);
|
||||
|
||||
spawn_local(async move {
|
||||
let response = task.await;
|
||||
loadable_result.set(LoadableResult::Loaded(response));
|
||||
});
|
||||
|
||||
|| ()
|
||||
},
|
||||
variables,
|
||||
)
|
||||
}
|
||||
loadable_result.clone()
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod attributes;
|
||||
pub mod common_component;
|
||||
pub mod cookies;
|
||||
pub mod form_utils;
|
||||
pub mod functional;
|
||||
pub mod graphql;
|
||||
pub mod modal;
|
||||
pub mod schema;
|
||||
pub mod tooltip;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![allow(clippy::empty_docs)]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
use validator::ValidationError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AttributeType {
|
||||
String,
|
||||
Integer,
|
||||
DateTime,
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
impl Display for AttributeType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AttributeType {
|
||||
type Err = ();
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"String" => Ok(AttributeType::String),
|
||||
"Integer" => Ok(AttributeType::Integer),
|
||||
"DateTime" => Ok(AttributeType::DateTime),
|
||||
"Jpeg" => Ok(AttributeType::Jpeg),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Macro to generate traits for converting between AttributeType and the
|
||||
// graphql generated equivalents.
|
||||
#[macro_export]
|
||||
macro_rules! convert_attribute_type {
|
||||
($source_type:ty) => {
|
||||
impl From<$source_type> for $crate::infra::schema::AttributeType {
|
||||
fn from(value: $source_type) -> Self {
|
||||
match value {
|
||||
<$source_type>::STRING => $crate::infra::schema::AttributeType::String,
|
||||
<$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
|
||||
<$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
|
||||
<$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
|
||||
_ => panic!("Unknown attribute type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$crate::infra::schema::AttributeType> for $source_type {
|
||||
fn from(value: $crate::infra::schema::AttributeType) -> Self {
|
||||
match value {
|
||||
$crate::infra::schema::AttributeType::String => <$source_type>::STRING,
|
||||
$crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
|
||||
$crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||
$crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
||||
AttributeType::from_str(attribute_type)
|
||||
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#![allow(clippy::empty_docs)]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||
pub type Tooltip;
|
||||
|
||||
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||
pub fn new(e: web_sys::Element) -> Tooltip;
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
pub mod components;
|
||||
pub mod infra;
|
||||
|
||||
use wasm_bindgen::prelude::{JsValue, wasm_bindgen};
|
||||
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run_app() -> Result<(), JsValue> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "lldap_auth"
|
||||
version = "0.6.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
description = "Authentication protocol for LLDAP"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
edition = "2021"
|
||||
homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap_auth"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.4.0"
|
||||
|
||||
[features]
|
||||
default = ["opaque_server", "opaque_client"]
|
||||
@@ -14,37 +14,30 @@ opaque_server = []
|
||||
opaque_client = []
|
||||
js = []
|
||||
sea_orm = ["dep:sea-orm"]
|
||||
test = []
|
||||
|
||||
[dependencies]
|
||||
rust-argon2 = "2"
|
||||
rust-argon2 = "0.8"
|
||||
curve25519-dalek = "3"
|
||||
digest = "0.9"
|
||||
generic-array = "0.14"
|
||||
rand = "0.8"
|
||||
serde = "*"
|
||||
sha2 = "0.9"
|
||||
thiserror = "2"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
thiserror = "*"
|
||||
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.7"
|
||||
version = "0.6"
|
||||
|
||||
[dependencies.chrono]
|
||||
version = "*"
|
||||
features = ["serde"]
|
||||
features = [ "serde" ]
|
||||
|
||||
[dependencies.sea-orm]
|
||||
workspace = true
|
||||
version= "0.12"
|
||||
default-features = false
|
||||
features = ["macros"]
|
||||
optional = true
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
# For WASM targets, use the JS getrandom.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
@@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
|
||||
pub mod access_control;
|
||||
pub mod opaque;
|
||||
|
||||
/// The messages for the 3-step OPAQUE and simple login process.
|
||||
@@ -109,7 +108,7 @@ pub mod types {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "sea_orm")]
|
||||
use sea_orm::{DbErr, DeriveValueType, TryFromU64, Value};
|
||||
use sea_orm::{DbErr, DeriveValueType, QueryResult, TryFromU64, Value};
|
||||
|
||||
#[derive(
|
||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||
@@ -152,22 +151,10 @@ pub mod types {
|
||||
}
|
||||
|
||||
#[derive(
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Clone,
|
||||
Default,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
derive_more::Debug,
|
||||
derive_more::Display,
|
||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||
#[serde(from = "CaseInsensitiveString")]
|
||||
#[debug(r#""{}""#, _0.as_str())]
|
||||
#[display("{}", _0.as_str())]
|
||||
pub struct UserId(CaseInsensitiveString);
|
||||
|
||||
impl UserId {
|
||||
@@ -189,6 +176,11 @@ pub mod types {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for UserId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sea_orm")]
|
||||
impl From<&UserId> for Value {
|
||||
@@ -32,6 +32,7 @@ impl ArgonHasher {
|
||||
lanes: 1,
|
||||
mem_cost: 50 * 1024, // 50 MB, in KB
|
||||
secret: &[],
|
||||
thread_mode: argon2::ThreadMode::Sequential,
|
||||
time_cost: 1,
|
||||
variant: argon2::Variant::Argon2id,
|
||||
version: argon2::Version::Version13,
|
||||
@@ -132,12 +133,6 @@ pub mod server {
|
||||
pub use super::*;
|
||||
pub type ServerRegistration = opaque_ke::ServerRegistration<DefaultSuite>;
|
||||
pub type ServerSetup = opaque_ke::ServerSetup<DefaultSuite>;
|
||||
|
||||
pub fn generate_random_private_key() -> ServerSetup {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
ServerSetup::new(&mut rng)
|
||||
}
|
||||
|
||||
/// Methods to register a new user, from the server side.
|
||||
pub mod registration {
|
||||
pub use super::*;
|
||||
@@ -1,26 +0,0 @@
|
||||
[package]
|
||||
name = "lldap_access_control"
|
||||
version = "0.1.0"
|
||||
description = "Access control wrappers for LLDAP"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tracing = "*"
|
||||
async-trait = "0.1"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.lldap_domain]
|
||||
path = "../domain"
|
||||
|
||||
[dependencies.lldap_domain_handlers]
|
||||
path = "../domain-handlers"
|
||||
|
||||
[dependencies.lldap_domain_model]
|
||||
path = "../domain-model"
|
||||
@@ -1,58 +0,0 @@
|
||||
use crate::types::UserId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
pub enum Permission {
|
||||
Admin,
|
||||
PasswordManager,
|
||||
Readonly,
|
||||
Regular,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ValidationResults {
|
||||
pub user: UserId,
|
||||
pub permission: Permission,
|
||||
}
|
||||
|
||||
impl ValidationResults {
|
||||
#[cfg(feature = "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
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
[package]
|
||||
name = "lldap_domain_handlers"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
test = []
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
base64 = "0.21"
|
||||
ldap3_proto = "0.6.0"
|
||||
serde_bytes = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display", "from", "from_str"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.lldap_domain]
|
||||
path = "../domain"
|
||||
|
||||
[dependencies.lldap_domain_model]
|
||||
path = "../domain-model"
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
[dependencies.uuid]
|
||||
features = ["v1", "v3"]
|
||||
version = "1"
|
||||
@@ -1 +0,0 @@
|
||||
pub mod handler;
|
||||
@@ -1,48 +0,0 @@
|
||||
[package]
|
||||
name = "lldap_domain_model"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
test = []
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.21"
|
||||
bincode = "1.3"
|
||||
orion = "0.17"
|
||||
serde_bytes = "0.11"
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display", "from", "from_str"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.lldap_domain]
|
||||
path = "../domain"
|
||||
|
||||
[dependencies.sea-orm]
|
||||
workspace = true
|
||||
features = ["macros"]
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
[dependencies.uuid]
|
||||
features = ["v1", "v3"]
|
||||
version = "1"
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod error;
|
||||
pub mod model;
|
||||
@@ -1,57 +0,0 @@
|
||||
use crate::error::DomainError;
|
||||
use lldap_domain::{
|
||||
schema::AttributeList,
|
||||
types::{Attribute, AttributeName, AttributeType, AttributeValue, Cardinality, Serialized},
|
||||
};
|
||||
|
||||
// Value must be a serialized attribute value of the type denoted by typ,
|
||||
// and either a singleton or unbounded list, depending on is_list.
|
||||
pub fn deserialize_attribute_value(
|
||||
value: &Serialized,
|
||||
typ: AttributeType,
|
||||
is_list: bool,
|
||||
) -> AttributeValue {
|
||||
match (typ, is_list) {
|
||||
(AttributeType::String, false) => {
|
||||
AttributeValue::String(Cardinality::Singleton(value.unwrap()))
|
||||
}
|
||||
(AttributeType::String, true) => {
|
||||
AttributeValue::String(Cardinality::Unbounded(value.unwrap()))
|
||||
}
|
||||
(AttributeType::Integer, false) => {
|
||||
AttributeValue::Integer(Cardinality::Singleton(value.unwrap::<i64>()))
|
||||
}
|
||||
(AttributeType::Integer, true) => {
|
||||
AttributeValue::Integer(Cardinality::Unbounded(value.unwrap()))
|
||||
}
|
||||
(AttributeType::DateTime, false) => {
|
||||
AttributeValue::DateTime(Cardinality::Singleton(value.unwrap()))
|
||||
}
|
||||
(AttributeType::DateTime, true) => {
|
||||
AttributeValue::DateTime(Cardinality::Unbounded(value.unwrap()))
|
||||
}
|
||||
(AttributeType::JpegPhoto, false) => {
|
||||
AttributeValue::JpegPhoto(Cardinality::Singleton(value.unwrap()))
|
||||
}
|
||||
(AttributeType::JpegPhoto, true) => {
|
||||
AttributeValue::JpegPhoto(Cardinality::Unbounded(value.unwrap()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize_attribute(
|
||||
name: AttributeName,
|
||||
value: &Serialized,
|
||||
schema: &AttributeList,
|
||||
) -> Result<Attribute, DomainError> {
|
||||
match schema.get_attribute_type(&name) {
|
||||
Some((typ, is_list)) => Ok(Attribute {
|
||||
name,
|
||||
value: deserialize_attribute_value(value, typ, is_list),
|
||||
}),
|
||||
None => Err(DomainError::InternalError(format!(
|
||||
"Unable to find schema for attribute named '{}'",
|
||||
name.into_string()
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use lldap_domain::types::LdapObjectClass;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "group_object_classes")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub lower_object_class: String,
|
||||
pub object_class: LdapObjectClass,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
impl From<Model> for LdapObjectClass {
|
||||
fn from(value: Model) -> Self {
|
||||
value.object_class
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use lldap_domain::types::LdapObjectClass;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user_object_classes")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub lower_object_class: String,
|
||||
pub object_class: LdapObjectClass,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
impl From<Model> for LdapObjectClass {
|
||||
fn from(value: Model) -> Self {
|
||||
value.object_class
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
[package]
|
||||
name = "lldap_domain"
|
||||
version = "0.1.0"
|
||||
authors = [
|
||||
"Valentin Tolmer <valentin@tolmer.fr>",
|
||||
"Simon Broeng Jensen <sbj@cwconsult.dk>",
|
||||
]
|
||||
edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
test = []
|
||||
|
||||
[dependencies]
|
||||
anyhow = "*"
|
||||
base64 = "0.21"
|
||||
bincode = "1.3"
|
||||
itertools = "0.10"
|
||||
juniper = "0.15"
|
||||
serde_bytes = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display", "from", "from_str"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.image]
|
||||
features = ["jpeg"]
|
||||
default-features = false
|
||||
version = "0.24"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.sea-orm]
|
||||
workspace = true
|
||||
features = [
|
||||
"macros",
|
||||
"with-chrono",
|
||||
"with-uuid",
|
||||
"sqlx-all",
|
||||
"runtime-actix-rustls",
|
||||
]
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
[dependencies.strum]
|
||||
features = ["derive"]
|
||||
version = "0.25"
|
||||
|
||||
[dependencies.uuid]
|
||||
features = ["v1", "v3"]
|
||||
version = "1"
|
||||
@@ -1,41 +0,0 @@
|
||||
use crate::types::{AttributeType, AttributeValue, JpegPhoto};
|
||||
use anyhow::{Context as AnyhowContext, Result, bail};
|
||||
|
||||
pub fn deserialize_attribute_value(
|
||||
value: &[String],
|
||||
typ: AttributeType,
|
||||
is_list: bool,
|
||||
) -> Result<AttributeValue> {
|
||||
if !is_list && value.len() != 1 {
|
||||
bail!("Attribute is not a list, but multiple values were provided",);
|
||||
}
|
||||
let parse_int = |value: &String| -> Result<i64> {
|
||||
value
|
||||
.parse::<i64>()
|
||||
.with_context(|| format!("Invalid integer value {}", value))
|
||||
};
|
||||
let parse_date = |value: &String| -> Result<chrono::NaiveDateTime> {
|
||||
Ok(chrono::DateTime::parse_from_rfc3339(value)
|
||||
.with_context(|| format!("Invalid date value {}", value))?
|
||||
.naive_utc())
|
||||
};
|
||||
let parse_photo = |value: &String| -> Result<JpegPhoto> {
|
||||
JpegPhoto::try_from(value.as_str()).context("Provided image is not a valid JPEG")
|
||||
};
|
||||
Ok(match (typ, is_list) {
|
||||
(AttributeType::String, false) => value[0].clone().into(),
|
||||
(AttributeType::String, true) => value.to_vec().into(),
|
||||
(AttributeType::Integer, false) => (parse_int(&value[0])?).into(),
|
||||
(AttributeType::Integer, true) => {
|
||||
(value.iter().map(parse_int).collect::<Result<Vec<_>>>()?).into()
|
||||
}
|
||||
(AttributeType::DateTime, false) => (parse_date(&value[0])?).into(),
|
||||
(AttributeType::DateTime, true) => {
|
||||
(value.iter().map(parse_date).collect::<Result<Vec<_>>>()?).into()
|
||||
}
|
||||
(AttributeType::JpegPhoto, false) => (parse_photo(&value[0])?).into(),
|
||||
(AttributeType::JpegPhoto, true) => {
|
||||
(value.iter().map(parse_photo).collect::<Result<Vec<_>>>()?).into()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod deserialize;
|
||||
pub mod public_schema;
|
||||
pub mod requests;
|
||||
pub mod schema;
|
||||
pub mod types;
|
||||
@@ -1,45 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::{Attribute, AttributeName, AttributeType, Email, GroupId, GroupName, UserId};
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct CreateUserRequest {
|
||||
// Same fields as User, but no creation_date, and with password.
|
||||
pub user_id: UserId,
|
||||
pub email: Email,
|
||||
pub display_name: Option<String>,
|
||||
pub attributes: Vec<Attribute>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct UpdateUserRequest {
|
||||
// Same fields as CreateUserRequest, but no with an extra layer of Option.
|
||||
pub user_id: UserId,
|
||||
pub email: Option<Email>,
|
||||
pub display_name: Option<String>,
|
||||
pub delete_attributes: Vec<AttributeName>,
|
||||
pub insert_attributes: Vec<Attribute>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct CreateGroupRequest {
|
||||
pub display_name: GroupName,
|
||||
pub attributes: Vec<Attribute>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UpdateGroupRequest {
|
||||
pub group_id: GroupId,
|
||||
pub display_name: Option<GroupName>,
|
||||
pub delete_attributes: Vec<AttributeName>,
|
||||
pub insert_attributes: Vec<Attribute>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CreateAttributeRequest {
|
||||
pub name: AttributeName,
|
||||
pub attribute_type: AttributeType,
|
||||
pub is_list: bool,
|
||||
pub is_visible: bool,
|
||||
pub is_editable: bool,
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::{AttributeName, AttributeType, LdapObjectClass};
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Schema {
|
||||
pub user_attributes: AttributeList,
|
||||
pub group_attributes: AttributeList,
|
||||
pub extra_user_object_classes: Vec<LdapObjectClass>,
|
||||
pub extra_group_object_classes: Vec<LdapObjectClass>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AttributeSchema {
|
||||
pub name: AttributeName,
|
||||
//TODO: pub aliases: Vec<String>,
|
||||
pub attribute_type: AttributeType,
|
||||
pub is_list: bool,
|
||||
pub is_visible: bool,
|
||||
pub is_editable: bool,
|
||||
pub is_hardcoded: bool,
|
||||
pub is_readonly: bool,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AttributeList {
|
||||
pub attributes: Vec<AttributeSchema>,
|
||||
}
|
||||
|
||||
impl AttributeList {
|
||||
pub fn get_attribute_schema(&self, name: &AttributeName) -> Option<&AttributeSchema> {
|
||||
self.attributes.iter().find(|a| a.name == *name)
|
||||
}
|
||||
|
||||
pub fn get_attribute_type(&self, name: &AttributeName) -> Option<(AttributeType, bool)> {
|
||||
self.get_attribute_schema(name)
|
||||
.map(|a| (a.attribute_type, a.is_list))
|
||||
}
|
||||
|
||||
pub fn format_for_ldap_schema_description(&self) -> String {
|
||||
self.attributes
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" $ ")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "lldap_frontend_options"
|
||||
version = "0.1.0"
|
||||
description = "Frontend options for LLDAP"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
@@ -1,6 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Options {
|
||||
pub password_reset_enabled: bool,
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
[package]
|
||||
name = "lldap_graphql_server"
|
||||
version = "0.1.0"
|
||||
description = "GraphQL server for LLDAP"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "*"
|
||||
juniper = "0.15"
|
||||
serde_json = "1"
|
||||
tracing = "*"
|
||||
urlencoding = "2"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.lldap_access_control]
|
||||
path = "../access-control"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.lldap_domain]
|
||||
path = "../domain"
|
||||
|
||||
[dependencies.lldap_domain_model]
|
||||
path = "../domain-model"
|
||||
|
||||
[dependencies.lldap_domain_handlers]
|
||||
path = "../domain-handlers"
|
||||
|
||||
[dependencies.lldap_ldap]
|
||||
path = "../ldap"
|
||||
|
||||
[dependencies.lldap_sql_backend_handler]
|
||||
path = "../sql-backend-handler"
|
||||
|
||||
[dependencies.lldap_validation]
|
||||
path = "../validation"
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
[dependencies.uuid]
|
||||
features = ["v1", "v3"]
|
||||
version = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = "0.11.4"
|
||||
pretty_assertions = "1"
|
||||
|
||||
#[dev-dependencies.lldap_auth]
|
||||
#path = "../auth"
|
||||
#features = ["test"]
|
||||
#
|
||||
#[dev-dependencies.lldap_opaque_handler]
|
||||
#path = "../opaque-handler"
|
||||
#features = ["test"]
|
||||
|
||||
[dev-dependencies.lldap_test_utils]
|
||||
path = "../test-utils"
|
||||
#
|
||||
#[dev-dependencies.lldap_sql_backend_handler]
|
||||
#path = "../sql-backend-handler"
|
||||
#features = ["test"]
|
||||
|
||||
[dev-dependencies.tokio]
|
||||
features = ["full"]
|
||||
version = "1.25"
|
||||
@@ -1,91 +0,0 @@
|
||||
use crate::{mutation::Mutation, query::Query};
|
||||
use juniper::{EmptySubscription, FieldError, RootNode};
|
||||
use lldap_access_control::{
|
||||
AccessControlledBackendHandler, AdminBackendHandler, ReadonlyBackendHandler,
|
||||
UserReadableBackendHandler, UserWriteableBackendHandler,
|
||||
};
|
||||
use lldap_auth::{access_control::ValidationResults, types::UserId};
|
||||
use lldap_domain_handlers::handler::BackendHandler;
|
||||
use tracing::debug;
|
||||
|
||||
pub struct Context<Handler: BackendHandler> {
|
||||
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 + use<Handler>)> {
|
||||
self.handler.get_admin_handler(&self.validation_result)
|
||||
}
|
||||
|
||||
pub fn get_readonly_handler(&self) -> Option<&(impl ReadonlyBackendHandler + use<Handler>)> {
|
||||
self.handler.get_readonly_handler(&self.validation_result)
|
||||
}
|
||||
|
||||
pub fn get_writeable_handler(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Option<&(impl UserWriteableBackendHandler + use<Handler>)> {
|
||||
self.handler
|
||||
.get_writeable_handler(&self.validation_result, user_id)
|
||||
}
|
||||
|
||||
pub fn get_readable_handler(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Option<&(impl UserReadableBackendHandler + use<Handler>)> {
|
||||
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>>>;
|
||||
|
||||
pub fn schema<Handler: BackendHandler>() -> Schema<Handler> {
|
||||
Schema::new(
|
||||
Query::<Handler>::new(),
|
||||
Mutation::<Handler>::new(),
|
||||
EmptySubscription::<Context<Handler>>::new(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn export_schema(output_file: Option<String>) -> anyhow::Result<()> {
|
||||
use anyhow::Context;
|
||||
use lldap_sql_backend_handler::SqlBackendHandler;
|
||||
let output = schema::<SqlBackendHandler>().as_schema_language();
|
||||
match output_file {
|
||||
None => println!("{}", output),
|
||||
Some(path) => {
|
||||
use std::fs::File;
|
||||
use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
let path = Path::new(&path);
|
||||
let mut file =
|
||||
File::create(path).context(format!("unable to open '{}'", path.display()))?;
|
||||
file.write_all(output.as_bytes())
|
||||
.context(format!("unable to write in '{}'", path.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
||||
[package]
|
||||
name = "lldap_ldap"
|
||||
version = "0.1.0"
|
||||
description = "LDAP operations support"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "*"
|
||||
ldap3_proto = "0.6.0"
|
||||
tracing = "*"
|
||||
itertools = "0.10"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["from"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.rand]
|
||||
features = ["small_rng", "getrandom"]
|
||||
version = "0.8"
|
||||
|
||||
[dependencies.uuid]
|
||||
version = "1"
|
||||
features = ["v1", "v3"]
|
||||
|
||||
[dependencies.lldap_access_control]
|
||||
path = "../access-control"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.lldap_domain]
|
||||
path = "../domain"
|
||||
|
||||
[dependencies.lldap_domain_handlers]
|
||||
path = "../domain-handlers"
|
||||
|
||||
[dependencies.lldap_domain_model]
|
||||
path = "../domain-model"
|
||||
|
||||
[dependencies.lldap_opaque_handler]
|
||||
path = "../opaque-handler"
|
||||
|
||||
[dev-dependencies.lldap_test_utils]
|
||||
path = "../test-utils"
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = "0.11.4"
|
||||
pretty_assertions = "1"
|
||||
|
||||
[dev-dependencies.tokio]
|
||||
features = ["full"]
|
||||
version = "1.25"
|
||||
|
||||
[dev-dependencies.lldap_domain]
|
||||
path = "../domain"
|
||||
features = ["test"]
|
||||
@@ -1,240 +0,0 @@
|
||||
use crate::core::error::{LdapError, LdapResult};
|
||||
use ldap3_proto::proto::{LdapCompareRequest, LdapOp, LdapResult as LdapResultOp, LdapResultCode};
|
||||
use lldap_domain::types::AttributeName;
|
||||
|
||||
pub fn compare(
|
||||
request: LdapCompareRequest,
|
||||
search_results: Vec<LdapOp>,
|
||||
base_dn_str: &str,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
if search_results.len() > 2 {
|
||||
// SearchResultEntry + SearchResultDone
|
||||
return Err(LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: "Too many search results".to_string(),
|
||||
});
|
||||
}
|
||||
let requested_attribute = AttributeName::from(&request.atype);
|
||||
match search_results.first() {
|
||||
Some(LdapOp::SearchResultEntry(entry)) => {
|
||||
let available = entry.attributes.iter().any(|attr| {
|
||||
AttributeName::from(&attr.atype) == requested_attribute
|
||||
&& attr.vals.contains(&request.val)
|
||||
});
|
||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
||||
code: if available {
|
||||
LdapResultCode::CompareTrue
|
||||
} else {
|
||||
LdapResultCode::CompareFalse
|
||||
},
|
||||
matcheddn: request.dn,
|
||||
message: "".to_string(),
|
||||
referral: vec![],
|
||||
})])
|
||||
}
|
||||
Some(LdapOp::SearchResultDone(_)) => Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
||||
code: LdapResultCode::NoSuchObject,
|
||||
matcheddn: base_dn_str.to_string(),
|
||||
message: "".to_string(),
|
||||
referral: vec![],
|
||||
})]),
|
||||
None => Err(LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: "Search request returned nothing".to_string(),
|
||||
}),
|
||||
_ => Err(LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: "Unexpected results from search".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::handler::tests::setup_bound_admin_handler;
|
||||
use chrono::TimeZone;
|
||||
use lldap_domain::{
|
||||
types::{Group, GroupId, User, UserAndGroups, UserId},
|
||||
uuid,
|
||||
};
|
||||
use lldap_domain_handlers::handler::{GroupRequestFilter, UserRequestFilter};
|
||||
use lldap_test_utils::MockTestBackendHandler;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compare_user() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_users().returning(|f, g| {
|
||||
assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob"))));
|
||||
assert!(!g);
|
||||
Ok(vec![UserAndGroups {
|
||||
user: User {
|
||||
user_id: UserId::new("bob"),
|
||||
email: "bob@bobmail.bob".into(),
|
||||
..Default::default()
|
||||
},
|
||||
groups: None,
|
||||
}])
|
||||
});
|
||||
mock.expect_list_groups().returning(|_| Ok(vec![]));
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let dn = "uid=bob,ou=people,dc=example,dc=com";
|
||||
let request = LdapCompareRequest {
|
||||
dn: dn.to_string(),
|
||||
atype: "uid".to_owned(),
|
||||
val: b"bob".to_vec(),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_compare(request).await,
|
||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
||||
code: LdapResultCode::CompareTrue,
|
||||
matcheddn: dn.to_string(),
|
||||
message: "".to_string(),
|
||||
referral: vec![],
|
||||
})])
|
||||
);
|
||||
// Non-canonical attribute.
|
||||
let request = LdapCompareRequest {
|
||||
dn: dn.to_string(),
|
||||
atype: "eMail".to_owned(),
|
||||
val: b"bob@bobmail.bob".to_vec(),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_compare(request).await,
|
||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
||||
code: LdapResultCode::CompareTrue,
|
||||
matcheddn: dn.to_string(),
|
||||
message: "".to_string(),
|
||||
referral: vec![],
|
||||
})])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compare_group() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_users().returning(|_, _| Ok(vec![]));
|
||||
mock.expect_list_groups().returning(|f| {
|
||||
assert_eq!(f, Some(GroupRequestFilter::DisplayName("group".into())));
|
||||
Ok(vec![Group {
|
||||
id: GroupId(1),
|
||||
display_name: "group".into(),
|
||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||
users: vec![UserId::new("bob")],
|
||||
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
||||
attributes: Vec::new(),
|
||||
}])
|
||||
});
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let dn = "uid=group,ou=groups,dc=example,dc=com";
|
||||
let request = LdapCompareRequest {
|
||||
dn: dn.to_string(),
|
||||
atype: "uid".to_owned(),
|
||||
val: b"group".to_vec(),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_compare(request).await,
|
||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
||||
code: LdapResultCode::CompareTrue,
|
||||
matcheddn: dn.to_string(),
|
||||
message: "".to_string(),
|
||||
referral: vec![],
|
||||
})])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compare_not_found() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_users().returning(|f, g| {
|
||||
assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob"))));
|
||||
assert!(!g);
|
||||
Ok(vec![])
|
||||
});
|
||||
mock.expect_list_groups().returning(|_| Ok(vec![]));
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let dn = "uid=bob,ou=people,dc=example,dc=com";
|
||||
let request = LdapCompareRequest {
|
||||
dn: dn.to_string(),
|
||||
atype: "uid".to_owned(),
|
||||
val: b"bob".to_vec(),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_compare(request).await,
|
||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
||||
code: LdapResultCode::NoSuchObject,
|
||||
matcheddn: "dc=example,dc=com".to_owned(),
|
||||
message: "".to_string(),
|
||||
referral: vec![],
|
||||
})])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compare_no_match() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_users().returning(|f, g| {
|
||||
assert_eq!(f, Some(UserRequestFilter::UserId(UserId::new("bob"))));
|
||||
assert!(!g);
|
||||
Ok(vec![UserAndGroups {
|
||||
user: User {
|
||||
user_id: UserId::new("bob"),
|
||||
email: "bob@bobmail.bob".into(),
|
||||
..Default::default()
|
||||
},
|
||||
groups: None,
|
||||
}])
|
||||
});
|
||||
mock.expect_list_groups().returning(|_| Ok(vec![]));
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let dn = "uid=bob,ou=people,dc=example,dc=com";
|
||||
let request = LdapCompareRequest {
|
||||
dn: dn.to_string(),
|
||||
atype: "mail".to_owned(),
|
||||
val: b"bob@bob".to_vec(),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_compare(request).await,
|
||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
||||
code: LdapResultCode::CompareFalse,
|
||||
matcheddn: dn.to_string(),
|
||||
message: "".to_string(),
|
||||
referral: vec![],
|
||||
})])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compare_group_member() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_users().returning(|_, _| Ok(vec![]));
|
||||
mock.expect_list_groups().returning(|f| {
|
||||
assert_eq!(f, Some(GroupRequestFilter::DisplayName("group".into())));
|
||||
Ok(vec![Group {
|
||||
id: GroupId(1),
|
||||
display_name: "group".into(),
|
||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||
users: vec![UserId::new("bob")],
|
||||
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
|
||||
attributes: Vec::new(),
|
||||
}])
|
||||
});
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let dn = "uid=group,ou=groups,dc=example,dc=com";
|
||||
let request = LdapCompareRequest {
|
||||
dn: dn.to_string(),
|
||||
atype: "uniqueMember".to_owned(),
|
||||
val: b"uid=bob,ou=people,dc=example,dc=com".to_vec(),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_compare(request).await,
|
||||
Ok(vec![LdapOp::CompareResult(LdapResultOp {
|
||||
code: LdapResultCode::CompareTrue,
|
||||
matcheddn: dn.to_owned(),
|
||||
message: "".to_string(),
|
||||
referral: vec![],
|
||||
})])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
use crate::core::{
|
||||
error::{LdapError, LdapResult},
|
||||
group::{REQUIRED_GROUP_ATTRIBUTES, get_default_group_object_classes},
|
||||
user::{REQUIRED_USER_ATTRIBUTES, get_default_user_object_classes},
|
||||
};
|
||||
use chrono::TimeZone;
|
||||
use itertools::join;
|
||||
use ldap3_proto::LdapResultCode;
|
||||
use lldap_domain::{
|
||||
public_schema::PublicSchema,
|
||||
schema::{AttributeList, Schema},
|
||||
types::{
|
||||
Attribute, AttributeName, AttributeType, AttributeValue, Cardinality, GroupName,
|
||||
LdapObjectClass, UserId,
|
||||
},
|
||||
};
|
||||
use lldap_domain_model::model::UserColumn;
|
||||
use std::collections::BTreeMap;
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
|
||||
where
|
||||
I: Iterator<Item = String>,
|
||||
{
|
||||
(|| {
|
||||
let pair = (
|
||||
iter.next().ok_or_else(|| "Empty DN element".to_string())?,
|
||||
iter.next().ok_or_else(|| "Missing DN value".to_string())?,
|
||||
);
|
||||
if let Some(e) = iter.next() {
|
||||
Err(format!(
|
||||
r#"Too many elements in distinguished name: "{}", "{}", "{}""#,
|
||||
pair.0, pair.1, e
|
||||
))
|
||||
} else {
|
||||
Ok(pair)
|
||||
}
|
||||
})()
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::InvalidDNSyntax,
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_distinguished_name(dn: &str) -> LdapResult<Vec<(String, String)>> {
|
||||
assert!(dn == dn.to_ascii_lowercase());
|
||||
dn.split(',')
|
||||
.map(|s| make_dn_pair(s.split('=').map(str::trim).map(String::from)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub enum UserOrGroupName {
|
||||
User(UserId),
|
||||
Group(GroupName),
|
||||
BadSubStree,
|
||||
UnexpectedFormat,
|
||||
InvalidSyntax(LdapError),
|
||||
}
|
||||
|
||||
impl UserOrGroupName {
|
||||
pub fn into_ldap_error(self, input: &str, expected_format: String) -> LdapError {
|
||||
LdapError {
|
||||
code: LdapResultCode::InvalidDNSyntax,
|
||||
message: match self {
|
||||
UserOrGroupName::BadSubStree => "Not a subtree of the base tree".to_string(),
|
||||
UserOrGroupName::InvalidSyntax(err) => return err,
|
||||
UserOrGroupName::UnexpectedFormat
|
||||
| UserOrGroupName::User(_)
|
||||
| UserOrGroupName::Group(_) => format!(
|
||||
r#"Unexpected DN format. Got "{}", expected: {}"#,
|
||||
input, expected_format
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_or_group_id_from_distinguished_name(
|
||||
dn: &str,
|
||||
base_tree: &[(String, String)],
|
||||
) -> UserOrGroupName {
|
||||
let parts = match parse_distinguished_name(dn) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return UserOrGroupName::InvalidSyntax(e),
|
||||
};
|
||||
if !is_subtree(&parts, base_tree) {
|
||||
return UserOrGroupName::BadSubStree;
|
||||
} else if parts.len() == base_tree.len() + 2
|
||||
&& parts[1].0 == "ou"
|
||||
&& (parts[0].0 == "cn" || parts[0].0 == "uid")
|
||||
{
|
||||
if parts[1].1 == "groups" {
|
||||
return UserOrGroupName::Group(GroupName::from(parts[0].1.clone()));
|
||||
} else if parts[1].1 == "people" {
|
||||
return UserOrGroupName::User(UserId::from(parts[0].1.clone()));
|
||||
}
|
||||
}
|
||||
UserOrGroupName::UnexpectedFormat
|
||||
}
|
||||
|
||||
pub fn get_user_id_from_distinguished_name(
|
||||
dn: &str,
|
||||
base_tree: &[(String, String)],
|
||||
base_dn_str: &str,
|
||||
) -> LdapResult<UserId> {
|
||||
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
|
||||
UserOrGroupName::User(user_id) => Ok(user_id),
|
||||
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=people,{}""#, base_dn_str))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_group_id_from_distinguished_name(
|
||||
dn: &str,
|
||||
base_tree: &[(String, String)],
|
||||
base_dn_str: &str,
|
||||
) -> LdapResult<GroupName> {
|
||||
match get_user_or_group_id_from_distinguished_name(dn, base_tree) {
|
||||
UserOrGroupName::Group(group_name) => Ok(group_name),
|
||||
err => Err(err.into_ldap_error(dn, format!(r#""uid=id,ou=groups,{}""#, base_dn_str))),
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_distinguished_name(dn: &str) -> bool {
|
||||
dn.contains('=') || dn.contains(',')
|
||||
}
|
||||
|
||||
pub fn get_user_id_from_distinguished_name_or_plain_name(
|
||||
dn: &str,
|
||||
base_tree: &[(String, String)],
|
||||
base_dn_str: &str,
|
||||
) -> LdapResult<UserId> {
|
||||
if !looks_like_distinguished_name(dn) {
|
||||
Ok(UserId::from(dn))
|
||||
} else {
|
||||
get_user_id_from_distinguished_name(dn, base_tree, base_dn_str)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_group_id_from_distinguished_name_or_plain_name(
|
||||
dn: &str,
|
||||
base_tree: &[(String, String)],
|
||||
base_dn_str: &str,
|
||||
) -> LdapResult<GroupName> {
|
||||
if !looks_like_distinguished_name(dn) {
|
||||
Ok(GroupName::from(dn))
|
||||
} else {
|
||||
get_group_id_from_distinguished_name(dn, base_tree, base_dn_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExpandedAttributes {
|
||||
// Lowercase name to original name.
|
||||
pub attribute_keys: BTreeMap<AttributeName, String>,
|
||||
pub include_custom_attributes: bool,
|
||||
}
|
||||
|
||||
#[instrument(skip(all_attribute_keys), level = "debug")]
|
||||
pub fn expand_attribute_wildcards(
|
||||
ldap_attributes: &[String],
|
||||
all_attribute_keys: &[&'static str],
|
||||
) -> ExpandedAttributes {
|
||||
let mut include_custom_attributes = false;
|
||||
let mut attributes_out: BTreeMap<_, _> = ldap_attributes
|
||||
.iter()
|
||||
.filter(|&s| s != "*" && s != "+" && s != "1.1")
|
||||
.map(|s| (AttributeName::from(s), s.to_string()))
|
||||
.collect();
|
||||
attributes_out.extend(
|
||||
if ldap_attributes.iter().any(|x| x == "*") || ldap_attributes.is_empty() {
|
||||
include_custom_attributes = true;
|
||||
all_attribute_keys
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
.iter()
|
||||
.map(|&s| (AttributeName::from(s), s.to_string())),
|
||||
);
|
||||
debug!(?attributes_out);
|
||||
ExpandedAttributes {
|
||||
attribute_keys: attributes_out,
|
||||
include_custom_attributes,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)]) -> bool {
|
||||
for (k, v) in subtree {
|
||||
assert!(k == &k.to_ascii_lowercase());
|
||||
assert!(v == &v.to_ascii_lowercase());
|
||||
}
|
||||
for (k, v) in base_tree {
|
||||
assert!(k == &k.to_ascii_lowercase());
|
||||
assert!(v == &v.to_ascii_lowercase());
|
||||
}
|
||||
if subtree.len() < base_tree.len() {
|
||||
return false;
|
||||
}
|
||||
let size_diff = subtree.len() - base_tree.len();
|
||||
for i in 0..base_tree.len() {
|
||||
if subtree[size_diff + i] != base_tree[i] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub enum UserFieldType {
|
||||
NoMatch,
|
||||
ObjectClass,
|
||||
MemberOf,
|
||||
Dn,
|
||||
EntryDn,
|
||||
PrimaryField(UserColumn),
|
||||
Attribute(AttributeName, AttributeType, bool),
|
||||
}
|
||||
|
||||
pub fn map_user_field(field: &AttributeName, schema: &PublicSchema) -> UserFieldType {
|
||||
match field.as_str() {
|
||||
"memberof" | "ismemberof" => UserFieldType::MemberOf,
|
||||
"objectclass" => UserFieldType::ObjectClass,
|
||||
"dn" | "distinguishedname" => UserFieldType::Dn,
|
||||
"entrydn" => UserFieldType::EntryDn,
|
||||
"uid" | "user_id" | "id" => UserFieldType::PrimaryField(UserColumn::UserId),
|
||||
"mail" | "email" => UserFieldType::PrimaryField(UserColumn::Email),
|
||||
"cn" | "displayname" | "display_name" => {
|
||||
UserFieldType::PrimaryField(UserColumn::DisplayName)
|
||||
}
|
||||
"givenname" | "first_name" | "firstname" => UserFieldType::Attribute(
|
||||
AttributeName::from("first_name"),
|
||||
AttributeType::String,
|
||||
false,
|
||||
),
|
||||
"sn" | "last_name" | "lastname" => UserFieldType::Attribute(
|
||||
AttributeName::from("last_name"),
|
||||
AttributeType::String,
|
||||
false,
|
||||
),
|
||||
"avatar" | "jpegphoto" => UserFieldType::Attribute(
|
||||
AttributeName::from("avatar"),
|
||||
AttributeType::JpegPhoto,
|
||||
false,
|
||||
),
|
||||
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
|
||||
UserFieldType::PrimaryField(UserColumn::CreationDate)
|
||||
}
|
||||
"entryuuid" | "uuid" => UserFieldType::PrimaryField(UserColumn::Uuid),
|
||||
_ => schema
|
||||
.get_schema()
|
||||
.user_attributes
|
||||
.get_attribute_type(field)
|
||||
.map(|(t, is_list)| UserFieldType::Attribute(field.clone(), t, is_list))
|
||||
.unwrap_or(UserFieldType::NoMatch),
|
||||
}
|
||||
}
|
||||
|
||||
pub enum GroupFieldType {
|
||||
NoMatch,
|
||||
GroupId,
|
||||
DisplayName,
|
||||
CreationDate,
|
||||
ObjectClass,
|
||||
Dn,
|
||||
// Like Dn, but returned as part of the attributes.
|
||||
EntryDn,
|
||||
Member,
|
||||
Uuid,
|
||||
Attribute(AttributeName, AttributeType, bool),
|
||||
}
|
||||
|
||||
pub fn map_group_field(field: &AttributeName, schema: &PublicSchema) -> GroupFieldType {
|
||||
match field.as_str() {
|
||||
"dn" | "distinguishedname" => GroupFieldType::Dn,
|
||||
"entrydn" => GroupFieldType::EntryDn,
|
||||
"objectclass" => GroupFieldType::ObjectClass,
|
||||
"cn" | "displayname" | "uid" | "display_name" | "id" => GroupFieldType::DisplayName,
|
||||
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
|
||||
GroupFieldType::CreationDate
|
||||
}
|
||||
"member" | "uniquemember" => GroupFieldType::Member,
|
||||
"entryuuid" | "uuid" => GroupFieldType::Uuid,
|
||||
"group_id" | "groupid" => GroupFieldType::GroupId,
|
||||
_ => schema
|
||||
.get_schema()
|
||||
.group_attributes
|
||||
.get_attribute_type(field)
|
||||
.map(|(t, is_list)| GroupFieldType::Attribute(field.clone(), t, is_list))
|
||||
.unwrap_or(GroupFieldType::NoMatch),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LdapInfo {
|
||||
pub base_dn: Vec<(String, String)>,
|
||||
pub base_dn_str: String,
|
||||
pub ignored_user_attributes: Vec<AttributeName>,
|
||||
pub ignored_group_attributes: Vec<AttributeName>,
|
||||
}
|
||||
|
||||
pub fn get_custom_attribute(
|
||||
attributes: &[Attribute],
|
||||
attribute_name: &AttributeName,
|
||||
) -> Option<Vec<Vec<u8>>> {
|
||||
let convert_date = |date| {
|
||||
chrono::Utc
|
||||
.from_utc_datetime(date)
|
||||
.to_rfc3339()
|
||||
.into_bytes()
|
||||
};
|
||||
attributes
|
||||
.iter()
|
||||
.find(|a| &a.name == attribute_name)
|
||||
.map(|attribute| match &attribute.value {
|
||||
AttributeValue::String(Cardinality::Singleton(s)) => {
|
||||
vec![s.clone().into_bytes()]
|
||||
}
|
||||
AttributeValue::String(Cardinality::Unbounded(l)) => {
|
||||
l.iter().map(|s| s.clone().into_bytes()).collect()
|
||||
}
|
||||
AttributeValue::Integer(Cardinality::Singleton(i)) => {
|
||||
// LDAP integers are encoded as strings.
|
||||
vec![i.to_string().into_bytes()]
|
||||
}
|
||||
AttributeValue::Integer(Cardinality::Unbounded(l)) => l
|
||||
.iter()
|
||||
// LDAP integers are encoded as strings.
|
||||
.map(|i| i.to_string().into_bytes())
|
||||
.collect(),
|
||||
AttributeValue::JpegPhoto(Cardinality::Singleton(p)) => {
|
||||
vec![p.clone().into_bytes()]
|
||||
}
|
||||
AttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => {
|
||||
l.iter().map(|p| p.clone().into_bytes()).collect()
|
||||
}
|
||||
AttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)],
|
||||
AttributeValue::DateTime(Cardinality::Unbounded(l)) => {
|
||||
l.iter().map(convert_date).collect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(derive_more::From)]
|
||||
pub struct ObjectClassList(Vec<LdapObjectClass>);
|
||||
|
||||
// See RFC4512 section 4.2.1 "objectClasses"
|
||||
impl ObjectClassList {
|
||||
pub fn format_for_ldap_schema_description(&self) -> String {
|
||||
join(self.0.iter().map(|c| format!("'{}'", c)), " ")
|
||||
}
|
||||
}
|
||||
|
||||
// See RFC4512 section 4.2 "Subschema Subentries"
|
||||
// This struct holds all information on what attributes and objectclasses are present on the server.
|
||||
// It can be used to 'index' a server using a LDAP subschema call.
|
||||
pub struct LdapSchemaDescription {
|
||||
base: PublicSchema,
|
||||
user_object_classes: ObjectClassList,
|
||||
group_object_classes: ObjectClassList,
|
||||
}
|
||||
|
||||
impl LdapSchemaDescription {
|
||||
pub fn from(schema: PublicSchema) -> Self {
|
||||
let mut user_object_classes = get_default_user_object_classes();
|
||||
user_object_classes.extend(schema.get_schema().extra_user_object_classes.clone());
|
||||
let mut group_object_classes = get_default_group_object_classes();
|
||||
group_object_classes.extend(schema.get_schema().extra_group_object_classes.clone());
|
||||
|
||||
Self {
|
||||
base: schema,
|
||||
user_object_classes: ObjectClassList(user_object_classes),
|
||||
group_object_classes: ObjectClassList(group_object_classes),
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(&self) -> &Schema {
|
||||
self.base.get_schema()
|
||||
}
|
||||
|
||||
pub fn user_object_classes(&self) -> &ObjectClassList {
|
||||
&self.user_object_classes
|
||||
}
|
||||
|
||||
pub fn group_object_classes(&self) -> &ObjectClassList {
|
||||
&self.group_object_classes
|
||||
}
|
||||
|
||||
pub fn required_user_attributes(&self) -> AttributeList {
|
||||
let attributes = self
|
||||
.schema()
|
||||
.user_attributes
|
||||
.attributes
|
||||
.iter()
|
||||
.filter(|a| REQUIRED_USER_ATTRIBUTES.contains(&a.name.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
AttributeList { attributes }
|
||||
}
|
||||
|
||||
pub fn optional_user_attributes(&self) -> AttributeList {
|
||||
let attributes = self
|
||||
.schema()
|
||||
.user_attributes
|
||||
.attributes
|
||||
.iter()
|
||||
.filter(|a| !REQUIRED_USER_ATTRIBUTES.contains(&a.name.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
AttributeList { attributes }
|
||||
}
|
||||
|
||||
pub fn required_group_attributes(&self) -> AttributeList {
|
||||
let attributes = self
|
||||
.schema()
|
||||
.group_attributes
|
||||
.attributes
|
||||
.iter()
|
||||
.filter(|a| REQUIRED_GROUP_ATTRIBUTES.contains(&a.name.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
AttributeList { attributes }
|
||||
}
|
||||
|
||||
pub fn optional_group_attributes(&self) -> AttributeList {
|
||||
let attributes = self
|
||||
.schema()
|
||||
.group_attributes
|
||||
.attributes
|
||||
.iter()
|
||||
.filter(|a| !REQUIRED_GROUP_ATTRIBUTES.contains(&a.name.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
AttributeList { attributes }
|
||||
}
|
||||
|
||||
// See RFC4512 section 4.2.2 "attributeTypes"
|
||||
// Parameter 'index_offset' is an offset for the enumeration of this list of attributes,
|
||||
// it has been preceeded by the list of hardcoded attributes.
|
||||
pub fn formatted_attribute_list(&self, index_offset: usize) -> Vec<Vec<u8>> {
|
||||
let mut formatted_list: Vec<Vec<u8>> = Vec::new();
|
||||
|
||||
for (index, attribute) in self.all_attributes().attributes.into_iter().enumerate() {
|
||||
formatted_list.push(
|
||||
format!(
|
||||
"( 2.{} NAME '{}' DESC 'LLDAP: {}' SUP {:?} )",
|
||||
(index + index_offset),
|
||||
attribute.name,
|
||||
if attribute.is_hardcoded {
|
||||
"builtin attribute"
|
||||
} else {
|
||||
"custom attribute"
|
||||
},
|
||||
attribute.attribute_type
|
||||
)
|
||||
.into_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
formatted_list
|
||||
}
|
||||
|
||||
pub fn all_attributes(&self) -> AttributeList {
|
||||
let mut combined_attributes = self.schema().user_attributes.attributes.clone();
|
||||
combined_attributes.extend_from_slice(&self.schema().group_attributes.attributes);
|
||||
AttributeList {
|
||||
attributes: combined_attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_subtree() {
|
||||
let subtree1 = &[
|
||||
("ou".to_string(), "people".to_string()),
|
||||
("dc".to_string(), "example".to_string()),
|
||||
("dc".to_string(), "com".to_string()),
|
||||
];
|
||||
let root = &[
|
||||
("dc".to_string(), "example".to_string()),
|
||||
("dc".to_string(), "com".to_string()),
|
||||
];
|
||||
assert!(is_subtree(subtree1, root));
|
||||
assert!(!is_subtree(&[], root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_distinguished_name() {
|
||||
let parsed_dn = &[
|
||||
("ou".to_string(), "people".to_string()),
|
||||
("dc".to_string(), "example".to_string()),
|
||||
("dc".to_string(), "com".to_string()),
|
||||
];
|
||||
assert_eq!(
|
||||
parse_distinguished_name("ou=people,dc=example,dc=com").expect("parsing failed"),
|
||||
parsed_dn
|
||||
);
|
||||
assert_eq!(
|
||||
parse_distinguished_name(" ou = people , dc = example , dc = com ")
|
||||
.expect("parsing failed"),
|
||||
parsed_dn
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
use crate::{
|
||||
core::{
|
||||
error::{LdapError, LdapResult},
|
||||
utils::{LdapInfo, UserOrGroupName, get_user_or_group_id_from_distinguished_name},
|
||||
},
|
||||
handler::make_add_response,
|
||||
};
|
||||
use ldap3_proto::proto::{
|
||||
LdapAddRequest, LdapAttribute, LdapOp, LdapPartialAttribute, LdapResultCode,
|
||||
};
|
||||
use lldap_access_control::AdminBackendHandler;
|
||||
use lldap_domain::{
|
||||
deserialize,
|
||||
requests::{CreateGroupRequest, CreateUserRequest},
|
||||
types::{Attribute, AttributeName, AttributeType, Email, GroupName, UserId},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tracing::instrument;
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
pub(crate) async fn create_user_or_group(
|
||||
backend_handler: &impl AdminBackendHandler,
|
||||
ldap_info: &LdapInfo,
|
||||
request: LdapAddRequest,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
let base_dn_str = &ldap_info.base_dn_str;
|
||||
match get_user_or_group_id_from_distinguished_name(&request.dn, &ldap_info.base_dn) {
|
||||
UserOrGroupName::User(user_id) => {
|
||||
create_user(backend_handler, user_id, request.attributes).await
|
||||
}
|
||||
UserOrGroupName::Group(group_name) => {
|
||||
create_group(backend_handler, group_name, request.attributes).await
|
||||
}
|
||||
err => Err(err.into_ldap_error(
|
||||
&request.dn,
|
||||
format!(
|
||||
r#""uid=id,ou=people,{}" or "uid=id,ou=groups,{}""#,
|
||||
base_dn_str, base_dn_str
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn create_user(
|
||||
backend_handler: &impl AdminBackendHandler,
|
||||
user_id: UserId,
|
||||
attributes: Vec<LdapAttribute>,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
fn parse_attribute(mut attr: LdapPartialAttribute) -> LdapResult<(String, Vec<u8>)> {
|
||||
if attr.vals.len() > 1 {
|
||||
Err(LdapError {
|
||||
code: LdapResultCode::ConstraintViolation,
|
||||
message: format!("Expected a single value for attribute {}", attr.atype),
|
||||
})
|
||||
} else {
|
||||
attr.atype.make_ascii_lowercase();
|
||||
match attr.vals.pop() {
|
||||
Some(val) => Ok((attr.atype, val)),
|
||||
None => Err(LdapError {
|
||||
code: LdapResultCode::ConstraintViolation,
|
||||
message: format!("Missing value for attribute {}", attr.atype),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
let attributes: HashMap<String, Vec<u8>> = attributes
|
||||
.into_iter()
|
||||
.filter(|a| !a.atype.eq_ignore_ascii_case("objectclass"))
|
||||
.map(parse_attribute)
|
||||
.collect::<LdapResult<_>>()?;
|
||||
fn decode_attribute_value(val: &[u8]) -> LdapResult<String> {
|
||||
std::str::from_utf8(val)
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::ConstraintViolation,
|
||||
message: format!(
|
||||
"Attribute value is invalid UTF-8: {:#?} (value {:?})",
|
||||
e, val
|
||||
),
|
||||
})
|
||||
.map(str::to_owned)
|
||||
}
|
||||
let get_attribute = |name| {
|
||||
attributes
|
||||
.get(name)
|
||||
.map(Vec::as_slice)
|
||||
.map(decode_attribute_value)
|
||||
};
|
||||
let make_encoded_attribute = |name: &str, typ: AttributeType, value: String| {
|
||||
Ok(Attribute {
|
||||
name: AttributeName::from(name),
|
||||
value: deserialize::deserialize_attribute_value(&[value], typ, false).map_err(|e| {
|
||||
LdapError {
|
||||
code: LdapResultCode::ConstraintViolation,
|
||||
message: format!("Invalid attribute value: {}", e),
|
||||
}
|
||||
})?,
|
||||
})
|
||||
};
|
||||
let mut new_user_attributes: Vec<Attribute> = Vec::new();
|
||||
if let Some(first_name) = get_attribute("givenname").transpose()? {
|
||||
new_user_attributes.push(make_encoded_attribute(
|
||||
"first_name",
|
||||
AttributeType::String,
|
||||
first_name,
|
||||
)?);
|
||||
}
|
||||
if let Some(last_name) = get_attribute("sn").transpose()? {
|
||||
new_user_attributes.push(make_encoded_attribute(
|
||||
"last_name",
|
||||
AttributeType::String,
|
||||
last_name,
|
||||
)?);
|
||||
}
|
||||
if let Some(avatar) = get_attribute("avatar").transpose()? {
|
||||
new_user_attributes.push(make_encoded_attribute(
|
||||
"avatar",
|
||||
AttributeType::JpegPhoto,
|
||||
avatar,
|
||||
)?);
|
||||
}
|
||||
backend_handler
|
||||
.create_user(CreateUserRequest {
|
||||
user_id,
|
||||
email: Email::from(
|
||||
get_attribute("mail")
|
||||
.or_else(|| get_attribute("email"))
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
display_name: get_attribute("cn").transpose()?,
|
||||
attributes: new_user_attributes,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: format!("Could not create user: {:#?}", e),
|
||||
})?;
|
||||
Ok(vec![make_add_response(
|
||||
LdapResultCode::Success,
|
||||
String::new(),
|
||||
)])
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn create_group(
|
||||
backend_handler: &impl AdminBackendHandler,
|
||||
group_name: GroupName,
|
||||
_attributes: Vec<LdapAttribute>,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
backend_handler
|
||||
.create_group(CreateGroupRequest {
|
||||
display_name: group_name,
|
||||
attributes: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: format!("Could not create group: {:#?}", e),
|
||||
})?;
|
||||
Ok(vec![make_add_response(
|
||||
LdapResultCode::Success,
|
||||
String::new(),
|
||||
)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::handler::tests::setup_bound_admin_handler;
|
||||
use lldap_domain::types::*;
|
||||
use lldap_test_utils::MockTestBackendHandler;
|
||||
use mockall::predicate::eq;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_user() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_create_user()
|
||||
.with(eq(CreateUserRequest {
|
||||
user_id: UserId::new("bob"),
|
||||
email: "".into(),
|
||||
display_name: Some("Bob".to_string()),
|
||||
..Default::default()
|
||||
}))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapAddRequest {
|
||||
dn: "uid=bob,ou=people,dc=example,dc=com".to_owned(),
|
||||
attributes: vec![LdapPartialAttribute {
|
||||
atype: "cn".to_owned(),
|
||||
vals: vec![b"Bob".to_vec()],
|
||||
}],
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.create_user_or_group(request).await,
|
||||
Ok(vec![make_add_response(
|
||||
LdapResultCode::Success,
|
||||
String::new()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_group() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_create_group()
|
||||
.with(eq(CreateGroupRequest {
|
||||
display_name: GroupName::new("bob"),
|
||||
..Default::default()
|
||||
}))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(GroupId(5)));
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapAddRequest {
|
||||
dn: "uid=bob,ou=groups,dc=example,dc=com".to_owned(),
|
||||
attributes: vec![LdapPartialAttribute {
|
||||
atype: "cn".to_owned(),
|
||||
vals: vec![b"Bobby".to_vec()],
|
||||
}],
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.create_user_or_group(request).await,
|
||||
Ok(vec![make_add_response(
|
||||
LdapResultCode::Success,
|
||||
String::new()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_user_multiple_object_class() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_create_user()
|
||||
.with(eq(CreateUserRequest {
|
||||
user_id: UserId::new("bob"),
|
||||
email: "".into(),
|
||||
display_name: Some("Bob".to_string()),
|
||||
..Default::default()
|
||||
}))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapAddRequest {
|
||||
dn: "uid=bob,ou=people,dc=example,dc=com".to_owned(),
|
||||
attributes: vec![
|
||||
LdapPartialAttribute {
|
||||
atype: "cn".to_owned(),
|
||||
vals: vec![b"Bob".to_vec()],
|
||||
},
|
||||
LdapPartialAttribute {
|
||||
atype: "objectClass".to_owned(),
|
||||
vals: vec![
|
||||
b"top".to_vec(),
|
||||
b"person".to_vec(),
|
||||
b"inetOrgPerson".to_vec(),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.create_user_or_group(request).await,
|
||||
Ok(vec![make_add_response(
|
||||
LdapResultCode::Success,
|
||||
String::new()
|
||||
)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
use crate::core::{
|
||||
error::{LdapError, LdapResult},
|
||||
utils::{LdapInfo, UserOrGroupName, get_user_or_group_id_from_distinguished_name},
|
||||
};
|
||||
use ldap3_proto::proto::{LdapOp, LdapResult as LdapResultOp, LdapResultCode};
|
||||
use lldap_access_control::AdminBackendHandler;
|
||||
use lldap_domain::types::{GroupName, UserId};
|
||||
use lldap_domain_handlers::handler::GroupRequestFilter;
|
||||
use lldap_domain_model::error::DomainError;
|
||||
use tracing::instrument;
|
||||
|
||||
pub(crate) fn make_del_response(code: LdapResultCode, message: String) -> LdapOp {
|
||||
LdapOp::DelResponse(LdapResultOp {
|
||||
code,
|
||||
matcheddn: "".to_string(),
|
||||
message,
|
||||
referral: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
pub(crate) async fn delete_user_or_group(
|
||||
backend_handler: &impl AdminBackendHandler,
|
||||
ldap_info: &LdapInfo,
|
||||
request: String,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
let base_dn_str = &ldap_info.base_dn_str;
|
||||
match get_user_or_group_id_from_distinguished_name(&request, &ldap_info.base_dn) {
|
||||
UserOrGroupName::User(user_id) => delete_user(backend_handler, user_id).await,
|
||||
UserOrGroupName::Group(group_name) => delete_group(backend_handler, group_name).await,
|
||||
err => Err(err.into_ldap_error(
|
||||
&request,
|
||||
format!(
|
||||
r#""uid=id,ou=people,{}" or "uid=id,ou=groups,{}""#,
|
||||
base_dn_str, base_dn_str
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn delete_user(
|
||||
backend_handler: &impl AdminBackendHandler,
|
||||
user_id: UserId,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
backend_handler
|
||||
.get_user_details(&user_id)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
DomainError::EntityNotFound(_) => LdapError {
|
||||
code: LdapResultCode::NoSuchObject,
|
||||
message: "Could not find user".to_string(),
|
||||
},
|
||||
e => LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: format!("Error while finding user: {:?}", e),
|
||||
},
|
||||
})?;
|
||||
backend_handler
|
||||
.delete_user(&user_id)
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: format!("Error while deleting user: {:?}", e),
|
||||
})?;
|
||||
Ok(vec![make_del_response(
|
||||
LdapResultCode::Success,
|
||||
String::new(),
|
||||
)])
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn delete_group(
|
||||
backend_handler: &impl AdminBackendHandler,
|
||||
group_name: GroupName,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
let groups = backend_handler
|
||||
.list_groups(Some(GroupRequestFilter::DisplayName(group_name.clone())))
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: format!("Error while finding group: {:?}", e),
|
||||
})?;
|
||||
let group_id = groups
|
||||
.iter()
|
||||
.find(|g| g.display_name == group_name)
|
||||
.map(|g| g.id)
|
||||
.ok_or_else(|| LdapError {
|
||||
code: LdapResultCode::NoSuchObject,
|
||||
message: "Could not find group".to_string(),
|
||||
})?;
|
||||
backend_handler
|
||||
.delete_group(group_id)
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: format!("Error while deleting group: {:?}", e),
|
||||
})?;
|
||||
Ok(vec![make_del_response(
|
||||
LdapResultCode::Success,
|
||||
String::new(),
|
||||
)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::handler::tests::setup_bound_admin_handler;
|
||||
use chrono::TimeZone;
|
||||
use lldap_domain::{
|
||||
types::{Group, GroupId, User},
|
||||
uuid,
|
||||
};
|
||||
use lldap_domain_model::error::DomainError;
|
||||
use lldap_test_utils::MockTestBackendHandler;
|
||||
use mockall::predicate::eq;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_user() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_details()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.return_once(|_| {
|
||||
Ok(User {
|
||||
user_id: UserId::new("bob"),
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
mock.expect_delete_user()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_del_response(
|
||||
LdapResultCode::Success,
|
||||
String::new()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_group() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_groups()
|
||||
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
|
||||
"bob",
|
||||
)))))
|
||||
.return_once(|_| {
|
||||
Ok(vec![Group {
|
||||
id: GroupId(34),
|
||||
display_name: GroupName::from("bob"),
|
||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||
users: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
}])
|
||||
});
|
||||
mock.expect_delete_group()
|
||||
.with(eq(GroupId(34)))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_del_response(
|
||||
LdapResultCode::Success,
|
||||
String::new()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_user_not_found() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_details()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.return_once(|_| Err(DomainError::EntityNotFound("No such user".to_string())));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_del_response(
|
||||
LdapResultCode::NoSuchObject,
|
||||
"Could not find user".to_string()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_user_lookup_error() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_details()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_del_response(
|
||||
LdapResultCode::OperationsError,
|
||||
r#"Error while finding user: InternalError("WTF?")"#.to_string()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_user_deletion_error() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_details()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.return_once(|_| {
|
||||
Ok(User {
|
||||
user_id: UserId::new("bob"),
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
mock.expect_delete_user()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.times(1)
|
||||
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::DelRequest("uid=bob,ou=people,dc=example,dc=com".to_owned());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_del_response(
|
||||
LdapResultCode::OperationsError,
|
||||
r#"Error while deleting user: InternalError("WTF?")"#.to_string()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_group_not_found() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_groups()
|
||||
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
|
||||
"bob",
|
||||
)))))
|
||||
.return_once(|_| Ok(vec![]));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_del_response(
|
||||
LdapResultCode::NoSuchObject,
|
||||
"Could not find group".to_string()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_group_lookup_error() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_groups()
|
||||
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
|
||||
"bob",
|
||||
)))))
|
||||
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_del_response(
|
||||
LdapResultCode::OperationsError,
|
||||
r#"Error while finding group: InternalError("WTF?")"#.to_string()
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_group_deletion_error() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_list_groups()
|
||||
.with(eq(Some(GroupRequestFilter::DisplayName(GroupName::from(
|
||||
"bob",
|
||||
)))))
|
||||
.return_once(|_| {
|
||||
Ok(vec![Group {
|
||||
id: GroupId(34),
|
||||
display_name: GroupName::from("bob"),
|
||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||
users: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
}])
|
||||
});
|
||||
mock.expect_delete_group()
|
||||
.with(eq(GroupId(34)))
|
||||
.times(1)
|
||||
.return_once(|_| Err(DomainError::InternalError("WTF?".to_string())));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::DelRequest("uid=bob,ou=groups,dc=example,dc=com".to_owned());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_del_response(
|
||||
LdapResultCode::OperationsError,
|
||||
r#"Error while deleting group: InternalError("WTF?")"#.to_string()
|
||||
)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
use crate::{
|
||||
compare,
|
||||
core::{
|
||||
error::{LdapError, LdapResult},
|
||||
utils::{LdapInfo, parse_distinguished_name},
|
||||
},
|
||||
create, delete, modify,
|
||||
password::{self, do_password_modification},
|
||||
search::{
|
||||
self, is_root_dse_request, is_subschema_entry_request, make_ldap_subschema_entry,
|
||||
make_search_error, make_search_request, make_search_success, root_dse_response,
|
||||
},
|
||||
};
|
||||
use ldap3_proto::proto::{
|
||||
LdapAddRequest, LdapBindRequest, LdapBindResponse, LdapCompareRequest, LdapExtendedRequest,
|
||||
LdapExtendedResponse, LdapFilter, LdapModifyRequest, LdapOp, LdapPasswordModifyRequest,
|
||||
LdapResult as LdapResultOp, LdapResultCode, LdapSearchRequest, OID_PASSWORD_MODIFY, OID_WHOAMI,
|
||||
};
|
||||
use lldap_access_control::AccessControlledBackendHandler;
|
||||
use lldap_auth::access_control::ValidationResults;
|
||||
use lldap_domain::{public_schema::PublicSchema, types::AttributeName};
|
||||
use lldap_domain_handlers::handler::{BackendHandler, LoginHandler, ReadSchemaBackendHandler};
|
||||
use lldap_opaque_handler::OpaqueHandler;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
use super::delete::make_del_response;
|
||||
|
||||
pub(crate) fn make_add_response(code: LdapResultCode, message: String) -> LdapOp {
|
||||
LdapOp::AddResponse(LdapResultOp {
|
||||
code,
|
||||
matcheddn: "".to_string(),
|
||||
message,
|
||||
referral: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn make_extended_response(code: LdapResultCode, message: String) -> LdapOp {
|
||||
LdapOp::ExtendedResponse(LdapExtendedResponse {
|
||||
res: LdapResultOp {
|
||||
code,
|
||||
matcheddn: "".to_string(),
|
||||
message,
|
||||
referral: vec![],
|
||||
},
|
||||
name: None,
|
||||
value: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn make_modify_response(code: LdapResultCode, message: String) -> LdapOp {
|
||||
LdapOp::ModifyResponse(LdapResultOp {
|
||||
code,
|
||||
matcheddn: "".to_string(),
|
||||
message,
|
||||
referral: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
pub struct LdapHandler<Backend> {
|
||||
user_info: Option<ValidationResults>,
|
||||
backend_handler: AccessControlledBackendHandler<Backend>,
|
||||
ldap_info: LdapInfo,
|
||||
session_uuid: uuid::Uuid,
|
||||
}
|
||||
|
||||
impl<Backend> LdapHandler<Backend> {
|
||||
pub fn session_uuid(&self) -> &uuid::Uuid {
|
||||
&self.session_uuid
|
||||
}
|
||||
}
|
||||
|
||||
impl<Backend: LoginHandler> LdapHandler<Backend> {
|
||||
pub fn get_login_handler(&self) -> &(impl LoginHandler + use<Backend>) {
|
||||
self.backend_handler.unsafe_get_handler()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Backend: OpaqueHandler> LdapHandler<Backend> {
|
||||
pub fn get_opaque_handler(&self) -> &(impl OpaqueHandler + use<Backend>) {
|
||||
self.backend_handler.unsafe_get_handler()
|
||||
}
|
||||
}
|
||||
|
||||
enum Credentials<'s> {
|
||||
Bound(&'s ValidationResults),
|
||||
Unbound(Vec<LdapOp>),
|
||||
}
|
||||
|
||||
impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend> {
|
||||
pub fn new(
|
||||
backend_handler: AccessControlledBackendHandler<Backend>,
|
||||
mut ldap_base_dn: String,
|
||||
ignored_user_attributes: Vec<AttributeName>,
|
||||
ignored_group_attributes: Vec<AttributeName>,
|
||||
session_uuid: uuid::Uuid,
|
||||
) -> Self {
|
||||
ldap_base_dn.make_ascii_lowercase();
|
||||
Self {
|
||||
user_info: None,
|
||||
backend_handler,
|
||||
ldap_info: LdapInfo {
|
||||
base_dn: parse_distinguished_name(&ldap_base_dn).unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"Invalid value for ldap_base_dn in configuration: {}",
|
||||
ldap_base_dn
|
||||
)
|
||||
}),
|
||||
base_dn_str: ldap_base_dn,
|
||||
ignored_user_attributes,
|
||||
ignored_group_attributes,
|
||||
},
|
||||
session_uuid,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_tests(backend_handler: Backend, ldap_base_dn: &str) -> Self {
|
||||
Self::new(
|
||||
AccessControlledBackendHandler::new(backend_handler),
|
||||
ldap_base_dn.to_string(),
|
||||
vec![],
|
||||
vec![],
|
||||
uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_credentials(&self) -> Credentials<'_> {
|
||||
match self.user_info.as_ref() {
|
||||
Some(user_info) => Credentials::Bound(user_info),
|
||||
None => Credentials::Unbound(vec![make_extended_response(
|
||||
LdapResultCode::InsufficentAccessRights,
|
||||
"No user currently bound".to_string(),
|
||||
)]),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn do_search_or_dse(&self, request: &LdapSearchRequest) -> LdapResult<Vec<LdapOp>> {
|
||||
if is_root_dse_request(request) {
|
||||
debug!("rootDSE request");
|
||||
return Ok(vec![
|
||||
root_dse_response(&self.ldap_info.base_dn_str),
|
||||
make_search_success(),
|
||||
]);
|
||||
} else if is_subschema_entry_request(request) {
|
||||
// See RFC4512 section 4.4 "Subschema discovery"
|
||||
debug!("Schema request");
|
||||
let backend_handler = self
|
||||
.user_info
|
||||
.as_ref()
|
||||
.and_then(|u| self.backend_handler.get_schema_only_handler(u))
|
||||
.ok_or_else(|| LdapError {
|
||||
code: LdapResultCode::InsufficentAccessRights,
|
||||
message: "No user currently bound".to_string(),
|
||||
})?;
|
||||
|
||||
let schema = backend_handler.get_schema().await.map_err(|e| LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: format!("Unable to get schema: {:#}", e),
|
||||
})?;
|
||||
return Ok(vec![
|
||||
make_ldap_subschema_entry(PublicSchema::from(schema)),
|
||||
make_search_success(),
|
||||
]);
|
||||
}
|
||||
self.do_search(request).await
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn do_search(&self, request: &LdapSearchRequest) -> LdapResult<Vec<LdapOp>> {
|
||||
let user_info = self.user_info.as_ref().ok_or_else(|| LdapError {
|
||||
code: LdapResultCode::InsufficentAccessRights,
|
||||
message: "No user currently bound".to_string(),
|
||||
})?;
|
||||
let backend_handler = self
|
||||
.backend_handler
|
||||
.get_user_restricted_lister_handler(user_info);
|
||||
search::do_search(&backend_handler, &self.ldap_info, request).await
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
|
||||
pub async fn do_bind(&mut self, request: &LdapBindRequest) -> Vec<LdapOp> {
|
||||
let (code, message) =
|
||||
match password::do_bind(&self.ldap_info, request, self.get_login_handler()).await {
|
||||
Ok(user_id) => {
|
||||
self.user_info = self
|
||||
.backend_handler
|
||||
.get_permissions_for_user(user_id)
|
||||
.await
|
||||
.ok();
|
||||
debug!("Success!");
|
||||
(LdapResultCode::Success, "".to_string())
|
||||
}
|
||||
Err(err) => (err.code, err.message),
|
||||
};
|
||||
vec![LdapOp::BindResponse(LdapBindResponse {
|
||||
res: LdapResultOp {
|
||||
code,
|
||||
matcheddn: "".to_string(),
|
||||
message,
|
||||
referral: vec![],
|
||||
},
|
||||
saslcreds: None,
|
||||
})]
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn do_extended_request(&self, request: &LdapExtendedRequest) -> Vec<LdapOp> {
|
||||
match request.name.as_str() {
|
||||
OID_PASSWORD_MODIFY => match LdapPasswordModifyRequest::try_from(request) {
|
||||
Ok(password_request) => {
|
||||
let credentials = match self.get_credentials() {
|
||||
Credentials::Bound(cred) => cred,
|
||||
Credentials::Unbound(err) => return err,
|
||||
};
|
||||
do_password_modification(
|
||||
credentials,
|
||||
&self.ldap_info,
|
||||
&self.backend_handler,
|
||||
self.get_opaque_handler(),
|
||||
&password_request,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e: LdapError| vec![make_extended_response(e.code, e.message)])
|
||||
}
|
||||
Err(e) => vec![make_extended_response(
|
||||
LdapResultCode::ProtocolError,
|
||||
format!("Error while parsing password modify request: {:#?}", e),
|
||||
)],
|
||||
},
|
||||
OID_WHOAMI => {
|
||||
let authz_id = self
|
||||
.user_info
|
||||
.as_ref()
|
||||
.map(|user_info| {
|
||||
format!(
|
||||
"dn:uid={},ou=people,{}",
|
||||
user_info.user.as_str(),
|
||||
self.ldap_info.base_dn_str
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
vec![make_extended_response(LdapResultCode::Success, authz_id)]
|
||||
}
|
||||
_ => vec![make_extended_response(
|
||||
LdapResultCode::UnwillingToPerform,
|
||||
format!("Unsupported extended operation: {}", &request.name),
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug", fields(dn = %request.dn))]
|
||||
pub async fn do_modify_request(&self, request: &LdapModifyRequest) -> Vec<LdapOp> {
|
||||
let credentials = match self.get_credentials() {
|
||||
Credentials::Bound(cred) => cred,
|
||||
Credentials::Unbound(err) => return err,
|
||||
};
|
||||
modify::handle_modify_request(
|
||||
self.get_opaque_handler(),
|
||||
|credentials, user_id| {
|
||||
self.backend_handler
|
||||
.get_readable_handler(credentials, &user_id)
|
||||
},
|
||||
&self.ldap_info,
|
||||
credentials,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e: LdapError| vec![make_modify_response(e.code, e.message)])
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
pub async fn create_user_or_group(&self, request: LdapAddRequest) -> LdapResult<Vec<LdapOp>> {
|
||||
let backend_handler = self
|
||||
.user_info
|
||||
.as_ref()
|
||||
.and_then(|u| self.backend_handler.get_admin_handler(u))
|
||||
.ok_or_else(|| LdapError {
|
||||
code: LdapResultCode::InsufficentAccessRights,
|
||||
message: "Unauthorized write".to_string(),
|
||||
})?;
|
||||
create::create_user_or_group(backend_handler, &self.ldap_info, request).await
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
pub async fn delete_user_or_group(&self, request: String) -> LdapResult<Vec<LdapOp>> {
|
||||
let backend_handler = self
|
||||
.user_info
|
||||
.as_ref()
|
||||
.and_then(|u| self.backend_handler.get_admin_handler(u))
|
||||
.ok_or_else(|| LdapError {
|
||||
code: LdapResultCode::InsufficentAccessRights,
|
||||
message: "Unauthorized write".to_string(),
|
||||
})?;
|
||||
delete::delete_user_or_group(backend_handler, &self.ldap_info, request).await
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
pub async fn do_compare(&self, request: LdapCompareRequest) -> LdapResult<Vec<LdapOp>> {
|
||||
let req = make_search_request::<String>(
|
||||
&self.ldap_info.base_dn_str,
|
||||
LdapFilter::Equality("dn".to_string(), request.dn.to_string()),
|
||||
vec![request.atype.clone()],
|
||||
);
|
||||
compare::compare(
|
||||
request,
|
||||
self.do_search(&req).await?,
|
||||
&self.ldap_info.base_dn_str,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn handle_ldap_message(&mut self, ldap_op: LdapOp) -> Option<Vec<LdapOp>> {
|
||||
Some(match ldap_op {
|
||||
LdapOp::BindRequest(request) => self.do_bind(&request).await,
|
||||
LdapOp::SearchRequest(request) => self
|
||||
.do_search_or_dse(&request)
|
||||
.await
|
||||
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
|
||||
LdapOp::UnbindRequest => {
|
||||
debug!(
|
||||
"Unbind request for {}",
|
||||
self.user_info
|
||||
.as_ref()
|
||||
.map(|u| u.user.as_str())
|
||||
.unwrap_or("<not bound>"),
|
||||
);
|
||||
self.user_info = None;
|
||||
// No need to notify on unbind (per rfc4511)
|
||||
return None;
|
||||
}
|
||||
LdapOp::ModifyRequest(request) => self.do_modify_request(&request).await,
|
||||
LdapOp::ExtendedRequest(request) => self.do_extended_request(&request).await,
|
||||
LdapOp::AddRequest(request) => self
|
||||
.create_user_or_group(request)
|
||||
.await
|
||||
.unwrap_or_else(|e: LdapError| vec![make_add_response(e.code, e.message)]),
|
||||
LdapOp::DelRequest(request) => self
|
||||
.delete_user_or_group(request)
|
||||
.await
|
||||
.unwrap_or_else(|e: LdapError| vec![make_del_response(e.code, e.message)]),
|
||||
LdapOp::CompareRequest(request) => self
|
||||
.do_compare(request)
|
||||
.await
|
||||
.unwrap_or_else(|e: LdapError| vec![make_search_error(e.code, e.message)]),
|
||||
op => vec![make_extended_response(
|
||||
LdapResultCode::UnwillingToPerform,
|
||||
format!("Unsupported operation: {:#?}", op),
|
||||
)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
use crate::password::tests::make_bind_success;
|
||||
use chrono::TimeZone;
|
||||
use ldap3_proto::proto::{LdapBindCred, LdapWhoamiRequest};
|
||||
use lldap_domain::{
|
||||
types::{GroupDetails, GroupId, UserId},
|
||||
uuid,
|
||||
};
|
||||
use lldap_domain_handlers::handler::*;
|
||||
use lldap_test_utils::{MockTestBackendHandler, setup_default_schema};
|
||||
use mockall::predicate::eq;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
use tokio;
|
||||
|
||||
pub fn make_user_search_request<S: Into<String>>(
|
||||
filter: LdapFilter,
|
||||
attrs: Vec<S>,
|
||||
) -> LdapSearchRequest {
|
||||
make_search_request::<S>("ou=people,Dc=example,dc=com", filter, attrs)
|
||||
}
|
||||
|
||||
pub fn make_group_search_request<S: Into<String>>(
|
||||
filter: LdapFilter,
|
||||
attrs: Vec<S>,
|
||||
) -> LdapSearchRequest {
|
||||
make_search_request::<S>("ou=groups,dc=example,dc=com", filter, attrs)
|
||||
}
|
||||
|
||||
pub async fn setup_bound_handler_with_group(
|
||||
mut mock: MockTestBackendHandler,
|
||||
group: &str,
|
||||
) -> LdapHandler<MockTestBackendHandler> {
|
||||
mock.expect_bind()
|
||||
.with(eq(BindRequest {
|
||||
name: UserId::new("test"),
|
||||
password: "pass".to_string(),
|
||||
}))
|
||||
.return_once(|_| Ok(()));
|
||||
let group = group.to_string();
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("test")))
|
||||
.return_once(|_| {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(GroupDetails {
|
||||
group_id: GroupId(42),
|
||||
display_name: group.into(),
|
||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||
attributes: Vec::new(),
|
||||
});
|
||||
Ok(set)
|
||||
});
|
||||
setup_default_schema(&mut mock);
|
||||
let mut ldap_handler = LdapHandler::new_for_tests(mock, "dc=Example,dc=com");
|
||||
let request = LdapBindRequest {
|
||||
dn: "uid=test,ou=people,dc=example,dc=coM".to_string(),
|
||||
cred: LdapBindCred::Simple("pass".to_string()),
|
||||
};
|
||||
assert_eq!(ldap_handler.do_bind(&request).await, make_bind_success());
|
||||
ldap_handler
|
||||
}
|
||||
|
||||
pub async fn setup_bound_readonly_handler(
|
||||
mock: MockTestBackendHandler,
|
||||
) -> LdapHandler<MockTestBackendHandler> {
|
||||
setup_bound_handler_with_group(mock, "lldap_strict_readonly").await
|
||||
}
|
||||
|
||||
pub async fn setup_bound_password_manager_handler(
|
||||
mock: MockTestBackendHandler,
|
||||
) -> LdapHandler<MockTestBackendHandler> {
|
||||
setup_bound_handler_with_group(mock, "lldap_password_manager").await
|
||||
}
|
||||
|
||||
pub async fn setup_bound_admin_handler(
|
||||
mock: MockTestBackendHandler,
|
||||
) -> LdapHandler<MockTestBackendHandler> {
|
||||
setup_bound_handler_with_group(mock, "lldap_admin").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_empty() {
|
||||
let mut ldap_handler =
|
||||
LdapHandler::new_for_tests(MockTestBackendHandler::new(), "dc=example,dc=com");
|
||||
let request = LdapOp::ExtendedRequest(LdapWhoamiRequest {}.into());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_extended_response(
|
||||
LdapResultCode::Success,
|
||||
"".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whoami_bound() {
|
||||
let mock = MockTestBackendHandler::new();
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::ExtendedRequest(LdapWhoamiRequest {}.into());
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_extended_response(
|
||||
LdapResultCode::Success,
|
||||
"dn:uid=test,ou=people,dc=example,dc=com".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
pub(crate) mod compare;
|
||||
pub(crate) mod core;
|
||||
pub(crate) mod create;
|
||||
pub(crate) mod delete;
|
||||
pub(crate) mod handler;
|
||||
pub(crate) mod modify;
|
||||
pub(crate) mod password;
|
||||
pub(crate) mod search;
|
||||
|
||||
pub use core::utils::{UserFieldType, map_group_field, map_user_field};
|
||||
pub use handler::LdapHandler;
|
||||
|
||||
pub use core::group::get_default_group_object_classes;
|
||||
pub use core::user::get_default_user_object_classes;
|
||||
@@ -1,305 +0,0 @@
|
||||
use crate::{
|
||||
core::{
|
||||
error::{LdapError, LdapResult},
|
||||
utils::{LdapInfo, get_user_id_from_distinguished_name},
|
||||
},
|
||||
handler::make_modify_response,
|
||||
password::{self},
|
||||
};
|
||||
use ldap3_proto::proto::{LdapModify, LdapModifyRequest, LdapModifyType, LdapOp, LdapResultCode};
|
||||
use lldap_access_control::UserReadableBackendHandler;
|
||||
use lldap_auth::access_control::ValidationResults;
|
||||
use lldap_domain::types::UserId;
|
||||
use lldap_opaque_handler::OpaqueHandler;
|
||||
|
||||
async fn handle_modify_change(
|
||||
opaque_handler: &impl OpaqueHandler,
|
||||
user_id: UserId,
|
||||
credentials: &ValidationResults,
|
||||
user_is_admin: bool,
|
||||
change: &LdapModify,
|
||||
) -> LdapResult<()> {
|
||||
if !change
|
||||
.modification
|
||||
.atype
|
||||
.eq_ignore_ascii_case("userpassword")
|
||||
|| change.operation != LdapModifyType::Replace
|
||||
{
|
||||
return Err(LdapError {
|
||||
code: LdapResultCode::UnwillingToPerform,
|
||||
message: format!(
|
||||
r#"Unsupported operation: `{:?}` for `{}`"#,
|
||||
change.operation, change.modification.atype
|
||||
),
|
||||
});
|
||||
}
|
||||
if !credentials.can_change_password(&user_id, user_is_admin) {
|
||||
return Err(LdapError {
|
||||
code: LdapResultCode::InsufficentAccessRights,
|
||||
message: format!(
|
||||
r#"User `{}` cannot modify the password of user `{}`"#,
|
||||
&credentials.user, &user_id
|
||||
),
|
||||
});
|
||||
}
|
||||
if let [value] = &change.modification.vals.as_slice() {
|
||||
password::change_password(opaque_handler, user_id, value)
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::Other,
|
||||
message: format!("Error while changing the password: {:#?}", e),
|
||||
})?;
|
||||
} else {
|
||||
return Err(LdapError {
|
||||
code: LdapResultCode::InvalidAttributeSyntax,
|
||||
message: format!(
|
||||
r#"Wrong number of values for password attribute: {}"#,
|
||||
change.modification.vals.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_modify_request<'cred, UserBackendHandler>(
|
||||
opaque_handler: &impl OpaqueHandler,
|
||||
get_readable_handler: impl FnOnce(
|
||||
&'cred ValidationResults,
|
||||
UserId,
|
||||
) -> Option<&'cred UserBackendHandler>,
|
||||
ldap_info: &LdapInfo,
|
||||
credentials: &'cred ValidationResults,
|
||||
request: &LdapModifyRequest,
|
||||
) -> LdapResult<Vec<LdapOp>>
|
||||
where
|
||||
// Note: ideally, get_readable_handler would take UserId by reference, but I couldn't make the lifetimes work.
|
||||
UserBackendHandler: UserReadableBackendHandler + 'cred,
|
||||
{
|
||||
match get_user_id_from_distinguished_name(
|
||||
&request.dn,
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
) {
|
||||
Ok(uid) => {
|
||||
let user_is_admin = get_readable_handler(credentials, uid.clone())
|
||||
.ok_or_else(|| LdapError {
|
||||
code: LdapResultCode::InsufficentAccessRights,
|
||||
message: format!(
|
||||
"User `{}` cannot modify user `{}`",
|
||||
credentials.user.as_str(),
|
||||
uid.as_str()
|
||||
),
|
||||
})?
|
||||
.get_user_groups(&uid)
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: format!("Internal error while requesting user's groups: {:#?}", e),
|
||||
})?
|
||||
.iter()
|
||||
.any(|g| g.display_name == "lldap_admin".into());
|
||||
for change in &request.changes {
|
||||
handle_modify_change(
|
||||
opaque_handler,
|
||||
uid.clone(),
|
||||
credentials,
|
||||
user_is_admin,
|
||||
change,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Ok(vec![make_modify_response(
|
||||
LdapResultCode::Success,
|
||||
String::new(),
|
||||
)])
|
||||
}
|
||||
Err(e) => Err(LdapError {
|
||||
code: LdapResultCode::InvalidDNSyntax,
|
||||
message: format!("Invalid username: {}", e),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
handler::tests::{
|
||||
setup_bound_admin_handler, setup_bound_handler_with_group,
|
||||
setup_bound_password_manager_handler,
|
||||
},
|
||||
password::tests::expect_password_change,
|
||||
};
|
||||
use chrono::TimeZone;
|
||||
use ldap3_proto::proto::LdapResult as LdapResultOp;
|
||||
use lldap_domain::{
|
||||
types::{GroupDetails, GroupId, GroupName, UserId},
|
||||
uuid,
|
||||
};
|
||||
use lldap_test_utils::MockTestBackendHandler;
|
||||
use mockall::predicate::eq;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn setup_target_user_groups(
|
||||
mock: &mut MockTestBackendHandler,
|
||||
target_user: &str,
|
||||
groups: Vec<&'static str>,
|
||||
) {
|
||||
mock.expect_get_user_groups()
|
||||
.times(1)
|
||||
.with(eq(UserId::from(target_user)))
|
||||
.return_once(move |_| {
|
||||
let mut g = HashSet::<GroupDetails>::new();
|
||||
for group in groups {
|
||||
g.insert(GroupDetails {
|
||||
group_id: GroupId(42),
|
||||
display_name: GroupName::from(group),
|
||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||
attributes: Vec::new(),
|
||||
});
|
||||
}
|
||||
Ok(g)
|
||||
});
|
||||
}
|
||||
|
||||
fn make_password_modify_request(target_user: &str) -> LdapModifyRequest {
|
||||
LdapModifyRequest {
|
||||
dn: format!("uid={},ou=people,dc=example,dc=com", target_user),
|
||||
changes: vec![LdapModify {
|
||||
operation: LdapModifyType::Replace,
|
||||
modification: ldap3_proto::LdapPartialAttribute {
|
||||
atype: "userPassword".to_string(),
|
||||
vals: vec![b"tommy".to_vec()],
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_modify_success_response() -> Vec<LdapOp> {
|
||||
vec![LdapOp::ModifyResponse(LdapResultOp {
|
||||
code: LdapResultCode::Success,
|
||||
matcheddn: "".to_string(),
|
||||
message: "".to_string(),
|
||||
referral: vec![],
|
||||
})]
|
||||
}
|
||||
|
||||
fn make_modify_failure_response(code: LdapResultCode, message: &str) -> Vec<LdapOp> {
|
||||
vec![LdapOp::ModifyResponse(LdapResultOp {
|
||||
code,
|
||||
matcheddn: "".to_string(),
|
||||
message: message.to_string(),
|
||||
referral: vec![],
|
||||
})]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_modify_password_of_regular_as_admin() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
setup_target_user_groups(&mut mock, "bob", Vec::new());
|
||||
expect_password_change(&mut mock, "bob");
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = make_password_modify_request("bob");
|
||||
assert_eq!(
|
||||
ldap_handler.do_modify_request(&request).await,
|
||||
make_modify_success_response()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_modify_password_of_regular_as_regular() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
setup_target_user_groups(&mut mock, "test", Vec::new());
|
||||
expect_password_change(&mut mock, "test");
|
||||
let ldap_handler = setup_bound_handler_with_group(mock, "regular").await;
|
||||
let request = make_password_modify_request("test");
|
||||
assert_eq!(
|
||||
ldap_handler.do_modify_request(&request).await,
|
||||
make_modify_success_response()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_modify_password_of_regular_as_password_manager() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
setup_target_user_groups(&mut mock, "bob", Vec::new());
|
||||
expect_password_change(&mut mock, "bob");
|
||||
let ldap_handler = setup_bound_password_manager_handler(mock).await;
|
||||
let request = make_password_modify_request("bob");
|
||||
assert_eq!(
|
||||
ldap_handler.do_modify_request(&request).await,
|
||||
make_modify_success_response()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_modify_password_of_admin_as_password_manager() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
setup_target_user_groups(&mut mock, "bob", vec!["lldap_admin"]);
|
||||
let ldap_handler = setup_bound_password_manager_handler(mock).await;
|
||||
let request = make_password_modify_request("bob");
|
||||
assert_eq!(
|
||||
ldap_handler.do_modify_request(&request).await,
|
||||
make_modify_failure_response(
|
||||
LdapResultCode::InsufficentAccessRights,
|
||||
"User `test` cannot modify the password of user `bob`"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_modify_password_of_other_regular_as_regular() {
|
||||
let ldap_handler =
|
||||
setup_bound_handler_with_group(MockTestBackendHandler::new(), "regular").await;
|
||||
let request = make_password_modify_request("bob");
|
||||
assert_eq!(
|
||||
ldap_handler.do_modify_request(&request).await,
|
||||
make_modify_failure_response(
|
||||
LdapResultCode::InsufficentAccessRights,
|
||||
"User `test` cannot modify user `bob`"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_modify_password_of_admin_as_admin() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
setup_target_user_groups(&mut mock, "test", vec!["lldap_admin"]);
|
||||
expect_password_change(&mut mock, "test");
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = make_password_modify_request("test");
|
||||
assert_eq!(
|
||||
ldap_handler.do_modify_request(&request).await,
|
||||
make_modify_success_response()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_modify_password_invalid_number_of_values() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
setup_target_user_groups(&mut mock, "bob", Vec::new());
|
||||
let ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = {
|
||||
let target_user = "bob";
|
||||
LdapModifyRequest {
|
||||
dn: format!("uid={},ou=people,dc=example,dc=com", target_user),
|
||||
changes: vec![LdapModify {
|
||||
operation: LdapModifyType::Replace,
|
||||
modification: ldap3_proto::LdapPartialAttribute {
|
||||
atype: "userPassword".to_string(),
|
||||
vals: vec![b"tommy".to_vec(), b"other_value".to_vec()],
|
||||
},
|
||||
}],
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_modify_request(&request).await,
|
||||
make_modify_failure_response(
|
||||
LdapResultCode::InvalidAttributeSyntax,
|
||||
"Wrong number of values for password attribute: 2"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
use crate::{
|
||||
core::{
|
||||
error::{LdapError, LdapResult},
|
||||
utils::{LdapInfo, get_user_id_from_distinguished_name},
|
||||
},
|
||||
handler::make_extended_response,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use ldap3_proto::proto::{
|
||||
LdapBindCred, LdapBindRequest, LdapOp, LdapPasswordModifyRequest, LdapResultCode,
|
||||
};
|
||||
use lldap_access_control::{AccessControlledBackendHandler, UserReadableBackendHandler};
|
||||
use lldap_auth::access_control::ValidationResults;
|
||||
use lldap_domain::types::UserId;
|
||||
use lldap_domain_handlers::handler::{BackendHandler, BindRequest, LoginHandler};
|
||||
use lldap_opaque_handler::OpaqueHandler;
|
||||
|
||||
pub(crate) async fn do_bind(
|
||||
ldap_info: &LdapInfo,
|
||||
request: &LdapBindRequest,
|
||||
login_handler: &impl LoginHandler,
|
||||
) -> LdapResult<UserId> {
|
||||
if request.dn.is_empty() {
|
||||
return Err(LdapError {
|
||||
code: LdapResultCode::InappropriateAuthentication,
|
||||
message: "Anonymous bind not allowed".to_string(),
|
||||
});
|
||||
}
|
||||
let user_id = match get_user_id_from_distinguished_name(
|
||||
&request.dn.to_ascii_lowercase(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return Err(LdapError {
|
||||
code: LdapResultCode::NamingViolation,
|
||||
message: e.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let password = if let LdapBindCred::Simple(password) = &request.cred {
|
||||
password
|
||||
} else {
|
||||
return Err(LdapError {
|
||||
code: LdapResultCode::UnwillingToPerform,
|
||||
message: "SASL not supported".to_string(),
|
||||
});
|
||||
};
|
||||
match login_handler
|
||||
.bind(BindRequest {
|
||||
name: user_id.clone(),
|
||||
password: password.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(user_id),
|
||||
Err(_) => Err(LdapError {
|
||||
code: LdapResultCode::InvalidCredentials,
|
||||
message: "".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn change_password<B: OpaqueHandler>(
|
||||
backend_handler: &B,
|
||||
user: UserId,
|
||||
password: &[u8],
|
||||
) -> Result<()> {
|
||||
use lldap_auth::*;
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let registration_start_request =
|
||||
opaque::client::registration::start_registration(password, &mut rng)?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: user.clone(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
let registration_start_response = backend_handler.registration_start(req).await?;
|
||||
let registration_finish = opaque::client::registration::finish_registration(
|
||||
registration_start_request.state,
|
||||
registration_start_response.registration_response,
|
||||
&mut rng,
|
||||
)?;
|
||||
let req = registration::ClientRegistrationFinishRequest {
|
||||
server_data: registration_start_response.server_data,
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
backend_handler.registration_finish(req).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn do_password_modification<Handler: BackendHandler>(
|
||||
credentials: &ValidationResults,
|
||||
ldap_info: &LdapInfo,
|
||||
backend_handler: &AccessControlledBackendHandler<Handler>,
|
||||
opaque_handler: &impl OpaqueHandler,
|
||||
request: &LdapPasswordModifyRequest,
|
||||
) -> LdapResult<Vec<LdapOp>> {
|
||||
match (&request.user_identity, &request.new_password) {
|
||||
(Some(user), Some(password)) => {
|
||||
match get_user_id_from_distinguished_name(
|
||||
&user.to_ascii_lowercase(),
|
||||
&ldap_info.base_dn,
|
||||
&ldap_info.base_dn_str,
|
||||
) {
|
||||
Ok(uid) => {
|
||||
let user_is_admin = backend_handler
|
||||
.get_readable_handler(credentials, &uid)
|
||||
.expect("Unexpected permission error")
|
||||
.get_user_groups(&uid)
|
||||
.await
|
||||
.map_err(|e| LdapError {
|
||||
code: LdapResultCode::OperationsError,
|
||||
message: format!(
|
||||
"Internal error while requesting user's groups: {:#?}",
|
||||
e
|
||||
),
|
||||
})?
|
||||
.iter()
|
||||
.any(|g| g.display_name == "lldap_admin".into());
|
||||
if !credentials.can_change_password(&uid, user_is_admin) {
|
||||
Err(LdapError {
|
||||
code: LdapResultCode::InsufficentAccessRights,
|
||||
message: format!(
|
||||
r#"User `{}` cannot modify the password of user `{}`"#,
|
||||
&credentials.user, &uid
|
||||
),
|
||||
})
|
||||
} else if let Err(e) =
|
||||
change_password(opaque_handler, uid, password.as_bytes()).await
|
||||
{
|
||||
Err(LdapError {
|
||||
code: LdapResultCode::Other,
|
||||
message: format!("Error while changing the password: {:#?}", e),
|
||||
})
|
||||
} else {
|
||||
Ok(vec![make_extended_response(
|
||||
LdapResultCode::Success,
|
||||
"".to_string(),
|
||||
)])
|
||||
}
|
||||
}
|
||||
Err(e) => Err(LdapError {
|
||||
code: LdapResultCode::InvalidDNSyntax,
|
||||
message: format!("Invalid username: {}", e),
|
||||
}),
|
||||
}
|
||||
}
|
||||
_ => Err(LdapError {
|
||||
code: LdapResultCode::ConstraintViolation,
|
||||
message: "Missing either user_id or password".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
use crate::handler::{
|
||||
LdapHandler, make_modify_response,
|
||||
tests::{
|
||||
setup_bound_admin_handler, setup_bound_password_manager_handler,
|
||||
setup_bound_readonly_handler,
|
||||
},
|
||||
};
|
||||
use chrono::TimeZone;
|
||||
use ldap3_proto::proto::{
|
||||
LdapBindResponse, LdapModify, LdapModifyRequest, LdapModifyType, LdapOp,
|
||||
LdapResult as LdapResultOp,
|
||||
};
|
||||
use ldap3_proto::{LdapPartialAttribute, proto::LdapExtendedRequest};
|
||||
use lldap_domain::{types::*, uuid};
|
||||
use lldap_test_utils::MockTestBackendHandler;
|
||||
use mockall::predicate::eq;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
use tokio;
|
||||
|
||||
pub fn make_bind_result(code: LdapResultCode, message: &str) -> Vec<LdapOp> {
|
||||
vec![LdapOp::BindResponse(LdapBindResponse {
|
||||
res: LdapResultOp {
|
||||
code,
|
||||
matcheddn: "".to_string(),
|
||||
message: message.to_string(),
|
||||
referral: vec![],
|
||||
},
|
||||
saslcreds: None,
|
||||
})]
|
||||
}
|
||||
|
||||
pub fn make_bind_success() -> Vec<LdapOp> {
|
||||
make_bind_result(LdapResultCode::Success, "")
|
||||
}
|
||||
|
||||
pub fn expect_password_change(mock: &mut MockTestBackendHandler, user: &str) {
|
||||
use lldap_auth::{opaque, registration};
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let registration_start_request =
|
||||
opaque::client::registration::start_registration("password".as_bytes(), &mut rng)
|
||||
.unwrap();
|
||||
let request = registration::ClientRegistrationStartRequest {
|
||||
username: user.into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
let start_response = opaque::server::registration::start_registration(
|
||||
&opaque::server::ServerSetup::new(&mut rng),
|
||||
request.registration_start_request,
|
||||
&request.username,
|
||||
)
|
||||
.unwrap();
|
||||
mock.expect_registration_start().times(1).return_once(|_| {
|
||||
Ok(registration::ServerRegistrationStartResponse {
|
||||
server_data: "".to_string(),
|
||||
registration_response: start_response.message,
|
||||
})
|
||||
});
|
||||
mock.expect_registration_finish()
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bind() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_bind()
|
||||
.with(eq(lldap_domain_handlers::handler::BindRequest {
|
||||
name: UserId::new("bob"),
|
||||
password: "pass".to_string(),
|
||||
}))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.return_once(|_| Ok(HashSet::new()));
|
||||
let mut ldap_handler = LdapHandler::new_for_tests(mock, "dc=eXample,dc=com");
|
||||
|
||||
let request = LdapOp::BindRequest(LdapBindRequest {
|
||||
dn: "uid=bob,ou=people,dc=example,dc=com".to_string(),
|
||||
cred: LdapBindCred::Simple("pass".to_string()),
|
||||
});
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await.unwrap(),
|
||||
make_bind_success()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_bind() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_bind()
|
||||
.with(eq(lldap_domain_handlers::handler::BindRequest {
|
||||
name: UserId::new("test"),
|
||||
password: "pass".to_string(),
|
||||
}))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("test")))
|
||||
.return_once(|_| {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(GroupDetails {
|
||||
group_id: GroupId(42),
|
||||
display_name: "lldap_admin".into(),
|
||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||
attributes: Vec::new(),
|
||||
});
|
||||
Ok(set)
|
||||
});
|
||||
let mut ldap_handler = LdapHandler::new_for_tests(mock, "dc=example,dc=com");
|
||||
|
||||
let request = LdapBindRequest {
|
||||
dn: "uid=test,ou=people,dc=example,dc=com".to_string(),
|
||||
cred: LdapBindCred::Simple("pass".to_string()),
|
||||
};
|
||||
assert_eq!(ldap_handler.do_bind(&request).await, make_bind_success());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bind_invalid_dn() {
|
||||
let mock = MockTestBackendHandler::new();
|
||||
let mut ldap_handler = LdapHandler::new_for_tests(mock, "dc=example,dc=com");
|
||||
|
||||
let request = LdapBindRequest {
|
||||
dn: "cn=bob,dc=example,dc=com".to_string(),
|
||||
cred: LdapBindCred::Simple("pass".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_bind(&request).await,
|
||||
make_bind_result(
|
||||
LdapResultCode::NamingViolation,
|
||||
r#"Unexpected DN format. Got "cn=bob,dc=example,dc=com", expected: "uid=id,ou=people,dc=example,dc=com""#
|
||||
),
|
||||
);
|
||||
let request = LdapBindRequest {
|
||||
dn: "uid=bob,dc=example,dc=com".to_string(),
|
||||
cred: LdapBindCred::Simple("pass".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_bind(&request).await,
|
||||
make_bind_result(
|
||||
LdapResultCode::NamingViolation,
|
||||
r#"Unexpected DN format. Got "uid=bob,dc=example,dc=com", expected: "uid=id,ou=people,dc=example,dc=com""#
|
||||
),
|
||||
);
|
||||
let request = LdapBindRequest {
|
||||
dn: "uid=bob,ou=groups,dc=example,dc=com".to_string(),
|
||||
cred: LdapBindCred::Simple("pass".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_bind(&request).await,
|
||||
make_bind_result(
|
||||
LdapResultCode::NamingViolation,
|
||||
r#"Unexpected DN format. Got "uid=bob,ou=groups,dc=example,dc=com", expected: "uid=id,ou=people,dc=example,dc=com""#
|
||||
),
|
||||
);
|
||||
let request = LdapBindRequest {
|
||||
dn: "uid=bob,ou=people,dc=example,dc=fr".to_string(),
|
||||
cred: LdapBindCred::Simple("pass".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_bind(&request).await,
|
||||
make_bind_result(
|
||||
LdapResultCode::NamingViolation,
|
||||
r#"Not a subtree of the base tree"#
|
||||
),
|
||||
);
|
||||
let request = LdapBindRequest {
|
||||
dn: "uid=bob=test,ou=people,dc=example,dc=com".to_string(),
|
||||
cred: LdapBindCred::Simple("pass".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
ldap_handler.do_bind(&request).await,
|
||||
make_bind_result(
|
||||
LdapResultCode::NamingViolation,
|
||||
r#"Too many elements in distinguished name: "uid", "bob", "test""#
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_password_change() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.returning(|_| Ok(HashSet::new()));
|
||||
expect_password_change(&mut mock, "bob");
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::ExtendedRequest(
|
||||
LdapPasswordModifyRequest {
|
||||
user_identity: Some("uid=bob,ou=people,dc=example,dc=com".to_string()),
|
||||
old_password: None,
|
||||
new_password: Some("password".to_string()),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_extended_response(
|
||||
LdapResultCode::Success,
|
||||
"".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_password_change_modify_request() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.returning(|_| Ok(HashSet::new()));
|
||||
use lldap_auth::*;
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let registration_start_request =
|
||||
opaque::client::registration::start_registration("password".as_bytes(), &mut rng)
|
||||
.unwrap();
|
||||
let request = registration::ClientRegistrationStartRequest {
|
||||
username: "bob".into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
let start_response = opaque::server::registration::start_registration(
|
||||
&opaque::server::ServerSetup::new(&mut rng),
|
||||
request.registration_start_request,
|
||||
&request.username,
|
||||
)
|
||||
.unwrap();
|
||||
mock.expect_registration_start().times(1).return_once(|_| {
|
||||
Ok(registration::ServerRegistrationStartResponse {
|
||||
server_data: "".to_string(),
|
||||
registration_response: start_response.message,
|
||||
})
|
||||
});
|
||||
mock.expect_registration_finish()
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::ModifyRequest(LdapModifyRequest {
|
||||
dn: "uid=bob,ou=people,dc=example,dc=com".to_string(),
|
||||
changes: vec![LdapModify {
|
||||
operation: LdapModifyType::Replace,
|
||||
modification: LdapPartialAttribute {
|
||||
atype: "userPassword".to_owned(),
|
||||
vals: vec!["password".as_bytes().to_vec()],
|
||||
},
|
||||
}],
|
||||
});
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_modify_response(
|
||||
LdapResultCode::Success,
|
||||
"".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_password_change_password_manager() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.returning(|_| Ok(HashSet::new()));
|
||||
use lldap_auth::*;
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let registration_start_request =
|
||||
opaque::client::registration::start_registration("password".as_bytes(), &mut rng)
|
||||
.unwrap();
|
||||
let request = registration::ClientRegistrationStartRequest {
|
||||
username: "bob".into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
let start_response = opaque::server::registration::start_registration(
|
||||
&opaque::server::ServerSetup::new(&mut rng),
|
||||
request.registration_start_request,
|
||||
&request.username,
|
||||
)
|
||||
.unwrap();
|
||||
mock.expect_registration_start().times(1).return_once(|_| {
|
||||
Ok(registration::ServerRegistrationStartResponse {
|
||||
server_data: "".to_string(),
|
||||
registration_response: start_response.message,
|
||||
})
|
||||
});
|
||||
mock.expect_registration_finish()
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
let mut ldap_handler = setup_bound_password_manager_handler(mock).await;
|
||||
let request = LdapOp::ExtendedRequest(
|
||||
LdapPasswordModifyRequest {
|
||||
user_identity: Some("uid=bob,ou=people,dc=example,dc=com".to_string()),
|
||||
old_password: None,
|
||||
new_password: Some("password".to_string()),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_extended_response(
|
||||
LdapResultCode::Success,
|
||||
"".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_password_change_errors() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.returning(|_| Ok(HashSet::new()));
|
||||
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||
let request = LdapOp::ExtendedRequest(
|
||||
LdapPasswordModifyRequest {
|
||||
user_identity: None,
|
||||
old_password: None,
|
||||
new_password: None,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_extended_response(
|
||||
LdapResultCode::ConstraintViolation,
|
||||
"Missing either user_id or password".to_string(),
|
||||
)])
|
||||
);
|
||||
let request = LdapOp::ExtendedRequest(
|
||||
LdapPasswordModifyRequest {
|
||||
user_identity: Some("uid=bob,ou=groups,ou=people,dc=example,dc=com".to_string()),
|
||||
old_password: None,
|
||||
new_password: Some("password".to_string()),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_extended_response(
|
||||
LdapResultCode::InvalidDNSyntax,
|
||||
r#"Invalid username: Unexpected DN format. Got "uid=bob,ou=groups,ou=people,dc=example,dc=com", expected: "uid=id,ou=people,dc=example,dc=com""#.to_string(),
|
||||
)])
|
||||
);
|
||||
let request = LdapOp::ExtendedRequest(LdapExtendedRequest {
|
||||
name: "test".to_string(),
|
||||
value: None,
|
||||
});
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_extended_response(
|
||||
LdapResultCode::UnwillingToPerform,
|
||||
"Unsupported extended operation: test".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_password_change_unauthorized_password_manager() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
let mut groups = HashSet::new();
|
||||
groups.insert(GroupDetails {
|
||||
group_id: GroupId(0),
|
||||
display_name: "lldap_admin".into(),
|
||||
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
|
||||
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||
attributes: Vec::new(),
|
||||
});
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(groups));
|
||||
let mut ldap_handler = setup_bound_password_manager_handler(mock).await;
|
||||
let request = LdapOp::ExtendedRequest(
|
||||
LdapPasswordModifyRequest {
|
||||
user_identity: Some("uid=bob,ou=people,dc=example,dc=com".to_string()),
|
||||
old_password: Some("pass".to_string()),
|
||||
new_password: Some("password".to_string()),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_extended_response(
|
||||
LdapResultCode::InsufficentAccessRights,
|
||||
"User `test` cannot modify the password of user `bob`".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_password_change_unauthorized_readonly() {
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq(UserId::new("bob")))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(HashSet::new()));
|
||||
let mut ldap_handler = setup_bound_readonly_handler(mock).await;
|
||||
let request = LdapOp::ExtendedRequest(
|
||||
LdapPasswordModifyRequest {
|
||||
user_identity: Some("uid=bob,ou=people,dc=example,dc=com".to_string()),
|
||||
old_password: Some("pass".to_string()),
|
||||
new_password: Some("password".to_string()),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
assert_eq!(
|
||||
ldap_handler.handle_ldap_message(request).await,
|
||||
Some(vec![make_extended_response(
|
||||
LdapResultCode::InsufficentAccessRights,
|
||||
"User `test` cannot modify the password of user `bob`".to_string(),
|
||||
)])
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user