Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6191fb226a | ||
|
|
9653d64eb1 | ||
|
|
5b5395103a | ||
|
|
a1e50defbe | ||
|
|
656451435e | ||
|
|
859ed97ca8 | ||
|
|
df889ee2fe | ||
|
|
faee271705 | ||
|
|
ba7848d043 | ||
|
|
45f5bb51d9 | ||
|
|
c0869b4324 | ||
|
|
edf9e538ce | ||
|
|
4a5abfd395 | ||
|
|
9fb4afcf60 | ||
|
|
789c8f367e | ||
|
|
db2b5cbae0 | ||
|
|
a13bfc3575 | ||
|
|
7b5ad47ee2 | ||
|
|
e1503743b5 | ||
|
|
88732556c1 | ||
|
|
35d0cc0fb0 | ||
|
|
6456149e50 | ||
|
|
f1bda21cad | ||
|
|
7b081fce61 | ||
|
|
618e7e3585 | ||
|
|
790fd7c5d1 | ||
|
|
4551e27b55 | ||
|
|
ad1ee52d76 | ||
|
|
9124339b96 | ||
|
|
617a0f53fa | ||
|
|
2a90443ed8 | ||
|
|
1d54ca8040 | ||
|
|
77ced7ea43 | ||
|
|
fa0105fa96 | ||
|
|
18e3892e55 | ||
|
|
350fdcdf9b | ||
|
|
adf088c74b | ||
|
|
c055c4e671 | ||
|
|
98a305e877 | ||
|
|
47ee56873e | ||
|
|
ee863f74fc | ||
|
|
24e3125e34 | ||
|
|
06b6653dff | ||
|
|
62745970c6 | ||
|
|
ea3142da5d | ||
|
|
656edc3763 | ||
|
|
d96b534921 | ||
|
|
9a024cd7fc | ||
|
|
c964428858 | ||
|
|
f98023e67f | ||
|
|
e68d46d4fe | ||
|
|
9a680a7d06 | ||
|
|
7345cc42d0 | ||
|
|
d60f5ab460 | ||
|
|
12dfa60eed | ||
|
|
158e4100ef | ||
|
|
87ebee672f | ||
|
|
ec6e1b0c09 | ||
|
|
640126f39a | ||
|
|
d31ca426f7 | ||
|
|
d4ac9fa703 | ||
|
|
5523d38838 | ||
|
|
587d724c2c | ||
|
|
29f3636064 | ||
|
|
ec69d30b1c | ||
|
|
232a41d053 | ||
|
|
540ac5d241 | ||
|
|
29962881cf | ||
|
|
65dd1d1fd3 | ||
|
|
ba72e622c2 | ||
|
|
5a5baf883f | ||
|
|
6c09af6479 | ||
|
|
ba1a5f6011 | ||
|
|
adc3d656cd | ||
|
|
b9f6b915ac | ||
|
|
43ffeca24d | ||
|
|
31e1ff358b | ||
|
|
026a2f7eb0 | ||
|
|
63f4bf95d2 | ||
|
|
d423c64d57 | ||
|
|
438ac2818a | ||
|
|
9874449d66 | ||
|
|
88ff3e7783 | ||
|
|
107c8ec96e | ||
|
|
5a00b7d8bb | ||
|
|
21e507a9d7 | ||
|
|
1859f5ddf0 | ||
|
|
de15ebba6a | ||
|
|
aa8bbf96f8 |
@@ -1,5 +1,7 @@
|
||||
# Don't track git
|
||||
.git/*
|
||||
.github/*
|
||||
.gitignore
|
||||
|
||||
# Don't track cargo generated files
|
||||
target/*
|
||||
@@ -18,5 +20,6 @@ Dockerfile
|
||||
lldap_config.toml
|
||||
server_key
|
||||
users.db*
|
||||
.gitignore
|
||||
screenshot.png
|
||||
recipe.json
|
||||
*.md
|
||||
|
||||
63
.github/workflows/docker.yml
vendored
Normal file
63
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64
|
||||
tags: nitnelave/lldap:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
-
|
||||
name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: nitnelave/lldap
|
||||
32
.github/workflows/rust.yml
vendored
32
.github/workflows/rust.yml
vendored
@@ -17,6 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
- name: Run tests
|
||||
@@ -42,6 +43,8 @@ jobs:
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
@@ -63,8 +66,37 @@ jobs:
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
|
||||
- name: Run cargo fmt
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
|
||||
coverage:
|
||||
name: Code coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup toolchain install nightly --component llvm-tools-preview
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
run: curl -LsSf https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz | tar xzf - -C ~/.cargo/bin
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
|
||||
- name: clean
|
||||
run: cargo llvm-cov clean --workspace
|
||||
- name: Generate code coverage for unit test
|
||||
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
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,3 +21,7 @@ package.json
|
||||
|
||||
# Server private key
|
||||
server_key
|
||||
|
||||
# Misc
|
||||
.env
|
||||
recipe.json
|
||||
|
||||
211
Cargo.lock
generated
211
Cargo.lock
generated
@@ -480,6 +480,18 @@ version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitvec"
|
||||
version = "0.19.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321"
|
||||
dependencies = [
|
||||
"funty",
|
||||
"radium",
|
||||
"tap",
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2b_simd"
|
||||
version = "0.5.11"
|
||||
@@ -997,6 +1009,15 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b394ed3d285a429378d3b384b9eb1285267e7df4b166df24b7a6939a04dc392e"
|
||||
dependencies = [
|
||||
"instant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figment"
|
||||
version = "0.10.6"
|
||||
@@ -1069,6 +1090,12 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69a039c3498dc930fe810151a34ba0c1c70b02b8625035592e74432f678591f2"
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.17"
|
||||
@@ -1443,6 +1470,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"match_cfg",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.4"
|
||||
@@ -1466,6 +1504,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
@@ -1644,15 +1688,41 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ldap3_server"
|
||||
version = "0.1.7"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3beb05c22d6cb1792389efb3e71ed90af6148b6f26d283db67322d356ab2556d"
|
||||
checksum = "092da326ef499380e33fc8213a621de7fb342d6cd112eb695e16161a0acb061a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"lber",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.10.0-rc.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8697ded52353bdd6fec234b3135972433397e86d0493d9fc38fbf407b7c106a"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"nom 6.1.2",
|
||||
"once_cell",
|
||||
"quoted_printable",
|
||||
"r2d2",
|
||||
"regex",
|
||||
"serde",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lexical-core"
|
||||
version = "0.7.6"
|
||||
@@ -1697,7 +1767,7 @@ checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
|
||||
|
||||
[[package]]
|
||||
name = "lldap"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-files",
|
||||
@@ -1725,13 +1795,16 @@ dependencies = [
|
||||
"juniper_actix",
|
||||
"jwt",
|
||||
"ldap3_server",
|
||||
"lettre",
|
||||
"lldap_auth",
|
||||
"log",
|
||||
"mockall",
|
||||
"opaque-ke",
|
||||
"openssl-sys",
|
||||
"orion",
|
||||
"rand 0.8.4",
|
||||
"sea-query",
|
||||
"secstr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -1740,6 +1813,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"time 0.2.27",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-actix-web",
|
||||
@@ -1749,7 +1823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lldap_app"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1773,7 +1847,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lldap_auth"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"curve25519-dalek",
|
||||
@@ -1785,8 +1859,6 @@ dependencies = [
|
||||
"rust-argon2",
|
||||
"serde",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"sqlx-core",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
@@ -1833,13 +1905,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.0.1"
|
||||
name = "match_cfg"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f099785f7595cc4b4553a174ce30dd7589ef93391ff414dbb67f62392b9e0ce1"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
|
||||
|
||||
[[package]]
|
||||
name = "matches"
|
||||
@@ -1989,6 +2058,18 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "6.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2"
|
||||
dependencies = [
|
||||
"bitvec",
|
||||
"funty",
|
||||
"memchr",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.0.0"
|
||||
@@ -2119,8 +2200,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-ke"
|
||||
version = "0.6.0-pre.1"
|
||||
source = "git+https://github.com/novifinancial/opaque-ke?rev=eb59676a940b15f77871aefe1e46d7b5bf85f40a#eb59676a940b15f77871aefe1e46d7b5bf85f40a"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26772682ba4fa69f11ae6e4af8bc83946372981ff31a026648d4acb2553c9ee8"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"curve25519-dalek",
|
||||
@@ -2157,6 +2239,15 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "111.16.0+1.1.1l"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ab2173f69416cf3ec12debb5823d244127d23a9b127d5a5189aa97c5fa2859f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.66"
|
||||
@@ -2166,6 +2257,7 @@ dependencies = [
|
||||
"autocfg 1.0.1",
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -2408,6 +2500,29 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1238256b09923649ec89b08104c4dfe9f6cb2fea734a5db5384e44916d59e9c5"
|
||||
|
||||
[[package]]
|
||||
name = "r2d2"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "545c5bc2b880973c9c10e4067418407a0ccaa3091781d1671d46eb35107cb26f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"parking_lot",
|
||||
"scheduled-thread-pool",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "radium"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.7.3"
|
||||
@@ -2509,15 +2624,6 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.25"
|
||||
@@ -2607,6 +2713,15 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scheduled-thread-pool"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6f74fd1204073fa02d5d5d68bec8021be4c38690b61264b2fdb48083d0e7d7"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.1.0"
|
||||
@@ -2635,6 +2750,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secstr"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cce2c726741c320e5b8f1edd9a21b3c2c292ae94514afd001d41d81ba143dafc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.4.2"
|
||||
@@ -3049,6 +3174,12 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.2.0"
|
||||
@@ -3286,9 +3417,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.19"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ca517f43f0fb96e0c3072ed5c275fe5eece87e8cb52f4a77b69226d3b1c9df8"
|
||||
checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
@@ -3314,36 +3445,18 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-serde"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb65ea441fbb84f9f6748fd496cf7f63ec9af5bca94dd86456978d055e8eb28b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.2.20"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cbe87a2fa7e35900ce5de20220a582a9483a7063811defce79d7cbd59d4cfe"
|
||||
checksum = "80a4ddde70311d8da398062ecf6fc2c309337de6b0f77d6c27aff8d53f6fca52"
|
||||
dependencies = [
|
||||
"ansi_term",
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
"matchers",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
"tracing-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3648,6 +3761,12 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214"
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "0.5.0"
|
||||
|
||||
57
Dockerfile
57
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Build image
|
||||
FROM rust:alpine AS chef
|
||||
FROM rust:alpine3.14 AS chef
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
@@ -9,12 +9,13 @@ RUN set -x \
|
||||
--ingroup app \
|
||||
--home /app \
|
||||
--uid 10001 \
|
||||
app
|
||||
RUN set -x \
|
||||
app \
|
||||
# Install required packages
|
||||
&& apk add npm openssl-dev musl-dev make perl
|
||||
&& apk add npm openssl-dev musl-dev make perl curl
|
||||
|
||||
USER app
|
||||
WORKDIR /app
|
||||
|
||||
RUN set -x \
|
||||
# Install build tools
|
||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||
@@ -24,44 +25,38 @@ RUN set -x \
|
||||
# Prepare the dependency list.
|
||||
FROM chef AS planner
|
||||
COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
RUN cargo chef prepare --recipe-path /tmp/recipe.json
|
||||
|
||||
# Build dependencies
|
||||
# Build dependencies.
|
||||
FROM chef AS builder
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN cargo chef cook --release -p lldap --recipe-path recipe.json \
|
||||
&& cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown
|
||||
COPY --from=planner /tmp/recipe.json recipe.json
|
||||
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||
&& cargo chef cook --release -p lldap
|
||||
|
||||
# Copy the source and build the app.
|
||||
# Copy the source and build the app and server.
|
||||
COPY --chown=app:app . .
|
||||
RUN cargo build --release -p lldap
|
||||
# TODO: release mode.
|
||||
RUN ./app/build.sh
|
||||
RUN cargo build --release -p lldap \
|
||||
# Build the frontend.
|
||||
&& ./app/build.sh
|
||||
|
||||
# Final image
|
||||
FROM alpine
|
||||
FROM alpine:3.14
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/app/index.html /app/app/main.js /app/app/style.css app/
|
||||
COPY --from=builder /app/app/pkg app/pkg
|
||||
COPY --from=builder /app/target/release/lldap lldap
|
||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
&& addgroup --gid 10001 app \
|
||||
&& adduser --disabled-password \
|
||||
--gecos '' \
|
||||
--ingroup app \
|
||||
--home /app \
|
||||
--uid 10001 \
|
||||
app
|
||||
|
||||
RUN mkdir /data && chown app:app /data
|
||||
USER app
|
||||
WORKDIR /app
|
||||
COPY --chown=app:app --from=builder /app/app/index.html app/index.html
|
||||
COPY --chown=app:app --from=builder /app/app/main.js app/main.js
|
||||
COPY --chown=app:app --from=builder /app/app/pkg app/pkg
|
||||
COPY --chown=app:app --from=builder /app/target/release/lldap lldap
|
||||
&& apk add --no-cache bash \
|
||||
&& chmod a+r -R .
|
||||
|
||||
ENV LDAP_PORT=3890
|
||||
ENV HTTP_PORT=17170
|
||||
|
||||
EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
||||
|
||||
CMD ["/app/lldap", "run", "--config-file", "/data/lldap_config.toml"]
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
|
||||
221
README.md
221
README.md
@@ -1,25 +1,51 @@
|
||||
# lldap - Light LDAP implementation for authentication
|
||||
<h1 align="center">lldap - Light LDAP implementation for authentication</h1>
|
||||
|
||||

|
||||

|
||||

|
||||
<p align="center">
|
||||
<i style="font-size:24px">LDAP made easy.</i>
|
||||
</p>
|
||||
|
||||
WARNING: This project is still in alpha, with the basic core functionality
|
||||
implemented but still very rough. For updates, follow
|
||||
[@nitnelave1](https://twitter.com/nitnelave1) or join our [Discord
|
||||
server](https://discord.gg/h5PEdRMNyP)!
|
||||
<p align="center">
|
||||
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||
<img
|
||||
src="https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg"
|
||||
alt="Build"/>
|
||||
</a>
|
||||
<a href="https://discord.gg/h5PEdRMNyP">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
|
||||
</a>
|
||||
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
|
||||
<img
|
||||
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
|
||||
alt="Twitter Follow"/>
|
||||
</a>
|
||||
<a href="https://github.com/rust-secure-code/safety-dance/">
|
||||
<img
|
||||
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
|
||||
alt="Unsafe forbidden"/>
|
||||
</a>
|
||||
<a href="https://app.codecov.io/gh/nitnelave/lldap">
|
||||
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/nitnelave/lldap" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## About
|
||||
|
||||
This project is an lightweight authentication server that provides an
|
||||
opinionated, simplified LDAP interface for authentication: clients that can
|
||||
only speak LDAP protocol can talk to it and use it as an authentication server.
|
||||
This project is a lightweight authentication server that provides an
|
||||
opinionated, simplified LDAP interface for authentication. It integrates with
|
||||
many backends, from KeyCloak to Authelia to Nextcloud and more!
|
||||
|
||||

|
||||
<img
|
||||
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
|
||||
alt="Screenshot of the user list page"
|
||||
width="50%"
|
||||
align="right"
|
||||
/>
|
||||
|
||||
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
||||
check out OpenLDAP. This server is a user management system that is:
|
||||
* simple to setup (no messing around with `slapd`)
|
||||
* simple to manage (friendly web UI)
|
||||
* simple to setup (no messing around with `slapd`),
|
||||
* simple to manage (friendly web UI),
|
||||
* low resources,
|
||||
* opinionated with basic defaults so you don't have to understand the
|
||||
subtleties of LDAP.
|
||||
|
||||
@@ -31,7 +57,7 @@ For more features (OAuth/OpenID support, reverse proxy, ...) you can install
|
||||
other components (KeyCloak, Authelia, ...) using this server as the source of
|
||||
truth for users, via LDAP.
|
||||
|
||||
## Setup
|
||||
## Installation
|
||||
|
||||
### With Docker
|
||||
|
||||
@@ -42,6 +68,10 @@ file (unless you move them in the config).
|
||||
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.
|
||||
|
||||
Secrets can also be set through a file. The filename should be specified by the variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_USER_PASS_FILE`, and the file contents are loaded into the respective configuration parameters. Note that `_FILE` variables take precedence.
|
||||
|
||||
Example for docker compose:
|
||||
|
||||
@@ -53,6 +83,8 @@ volumes:
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/lldap
|
||||
# Change this to the user:group you want.
|
||||
user: "33:33"
|
||||
ports:
|
||||
# For LDAP
|
||||
- "3890:3890"
|
||||
@@ -60,10 +92,12 @@ services:
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
- "lldap_data:/data"
|
||||
# Alternatively, you can mount a local folder
|
||||
# - "./lldap_data:/data"
|
||||
environment:
|
||||
- JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LDAP_BASE_DN=dc=example,dc=com
|
||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
```
|
||||
|
||||
Then the service will listen on two ports, one for LDAP and one for the web
|
||||
@@ -85,11 +119,36 @@ To bring up the server, just run `cargo run`. The default config is in
|
||||
`lldap_config.toml`, setting environment variables or passing arguments to
|
||||
`cargo run`.
|
||||
|
||||
### Cross-compilation
|
||||
|
||||
No Docker image is provided for other architectures, due to the difficulty of
|
||||
setting up cross-compilation inside a Docker image.
|
||||
|
||||
Some pre-compiled binaries are provided for each release, starting with 0.2.
|
||||
|
||||
If you want to cross-compile, 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).
|
||||
|
||||
## Client configuration
|
||||
|
||||
To configure the services that will talk to LLDAP, here are the values:
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,dc=example,dc=com`.
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
- The LDAP password is from the configuration (same as to log in to the web
|
||||
UI).
|
||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||
@@ -103,6 +162,46 @@ 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.
|
||||
|
||||
### 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:
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [Jisti Meet](example_configs/jitsi_meet.conf)
|
||||
|
||||
## Comparisons with other services
|
||||
|
||||
### vs OpenLDAP
|
||||
|
||||
OpenLDAP is a monster of a service that implements all of LDAP and all of its
|
||||
extensions, plus some of its own. That said, if you need all that flexibility,
|
||||
it might be what you need! Note that installation can be a bit painful
|
||||
(figuring out how to use `slapd`) and people have mixed experiences following
|
||||
tutorials online. If you don't configure it properly, you might end up storing
|
||||
passwords in clear, so a breach of your server would reveal all the stored
|
||||
passwords!
|
||||
|
||||
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
||||
install one (not that many that look nice) and configure it.
|
||||
|
||||
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 wed UI.
|
||||
|
||||
### vs FreeIPA
|
||||
|
||||
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||
management, it also does security policies, single sign-on, certificate
|
||||
management, linux account management and so on.
|
||||
|
||||
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 (<100 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.
|
||||
|
||||
## I can't log in!
|
||||
|
||||
If you just set up the server, can get to the login page but the password you
|
||||
@@ -122,90 +221,6 @@ set isn't working, try the following:
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the [Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
|
||||
## Architecture
|
||||
|
||||
The server is entirely written in Rust, using [actix](https://actix.rs) for the
|
||||
backend and [yew](https://yew.rs) for the frontend.
|
||||
|
||||
Backend:
|
||||
* Listens on a port for LDAP protocol.
|
||||
* Only a small, read-only subset of the LDAP protocol is supported.
|
||||
* An extension to allow resetting the password through LDAP will be added.
|
||||
* Listens on another port for HTTP traffic.
|
||||
* The authentication API, based on JWTs, is under "/auth".
|
||||
* The user management API is a GraphQL API under "/api/graphql". The schema
|
||||
is defined in `schema.graphql`.
|
||||
* The static frontend files are served by this port too.
|
||||
|
||||
Note that secure protocols (LDAPS, HTTPS) are currently not supported. This can
|
||||
be worked around by using a reverse proxy in front of the server (for the HTTP
|
||||
API) that wraps/unwraps the HTTPS messages, or only open the service to
|
||||
localhost or other trusted docker containers (for the LDAP API).
|
||||
|
||||
Frontend:
|
||||
* User management UI.
|
||||
* Written in Rust compiled to WASM as an SPA with the Yew library.
|
||||
* Based on components, with a React-like organization.
|
||||
|
||||
Data storage:
|
||||
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
|
||||
* Currently only SQLite is supported (see
|
||||
https://github.com/launchbadge/sqlx/issues/1225 for what blocks us from
|
||||
supporting more SQL backends).
|
||||
|
||||
### Code organization
|
||||
|
||||
* `auth/`: Contains the shared structures needed for authentication, the
|
||||
interface between front and back-end. In particular, it contains the OPAQUE
|
||||
structures and the JWT format.
|
||||
* `app/`: The frontend.
|
||||
* `src/components`: The elements containing the business and display logic of
|
||||
the various pages and their components.
|
||||
* `src/infra`: Various tools and utilities.
|
||||
* `server/`: The backend.
|
||||
* `src/domain/`: Domain-specific logic: users, groups, checking passwords...
|
||||
* `src/infra/`: API, both GraphQL and LDAP
|
||||
|
||||
## Authentication
|
||||
|
||||
### Passwords
|
||||
|
||||
Passwords are hashed using Argon2, the state of the art in terms of password
|
||||
storage. They are hashed using a secret provided in the configuration (which
|
||||
can be given as environment variable or command line argument as well): this
|
||||
should be kept secret and shouldn't change (it would invalidate all passwords).
|
||||
|
||||
Authentication is done via the OPAQUE protocol, meaning that the passwords are
|
||||
never sent to the server, but instead the client proves that they know the
|
||||
correct password (zero-knowledge proof). This is likely overkill, especially
|
||||
considered that the LDAP interface requires sending the password to the server,
|
||||
but it's one less potential flaw (especially since the LDAP interface can be
|
||||
restricted to an internal docker-only network while the web app is exposed to
|
||||
the Internet).
|
||||
|
||||
### JWTs and refresh tokens
|
||||
|
||||
When logging in for the first time, users are provided with a refresh token
|
||||
that gets stored in an HTTP-only cookie, valid for 30 days. They can use this
|
||||
token to get a JWT to get access to various servers: the JWT lists the groups
|
||||
the user belongs to. To simplify the setup, there is a single JWT secret that
|
||||
should be shared between the authentication server and the application servers;
|
||||
and users don't get a different token per application server
|
||||
(this could be implemented, we just didn't have any use case yet).
|
||||
|
||||
JWTs are only valid for one day: when they expire, a new JWT can be obtained
|
||||
from the authentication server using the refresh token. If the user stays
|
||||
logged in, they would only have to type their password once a month.
|
||||
|
||||
#### Logout
|
||||
|
||||
In order to handle logout correctly, we rely on a blacklist of JWTs. When a
|
||||
user logs out, their refresh token is removed from the backend, and all of
|
||||
their currently valid JWTs are added to a blacklist. Incoming requests are
|
||||
checked against this blacklist (in-memory, faster than calling the database).
|
||||
Applications that want to use these JWTs should subscribe to be notified of
|
||||
blacklisted JWTs (TODO: implement the PubSub service and API).
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are welcome! Just fork and open a PR. Or just file a bug.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "lldap_app"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
use crate::{
|
||||
components::select::{Select, SelectOption, SelectOptionProps},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use std::collections::HashSet;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -33,14 +29,11 @@ pub struct ListUserNames;
|
||||
pub type User = list_user_names::ListUserNamesUsers;
|
||||
|
||||
pub struct AddGroupMemberComponent {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The list of existing users, initially not loaded.
|
||||
user_list: Option<Vec<User>>,
|
||||
/// The currently selected user.
|
||||
selected_user: Option<User>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -58,58 +51,24 @@ pub struct Props {
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
impl AddGroupMemberComponent {
|
||||
fn get_user_list(&mut self) {
|
||||
self.task = HostService::graphql_query::<ListUserNames>(
|
||||
list_user_names::Variables { filters: None },
|
||||
self.link.callback(Msg::UserListResponse),
|
||||
"Error trying to fetch user list",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn submit_add_member(&mut self) -> Result<bool> {
|
||||
let user_id = match self.selected_user.clone() {
|
||||
None => return Ok(false),
|
||||
Some(user) => user.id,
|
||||
};
|
||||
self.task = HostService::graphql_query::<AddUserToGroup>(
|
||||
add_user_to_group::Variables {
|
||||
user: user_id,
|
||||
group: self.props.group_id,
|
||||
},
|
||||
self.link.callback(Msg::AddMemberResponse),
|
||||
"Error trying to initiate adding the user to a group",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserListResponse(response) => {
|
||||
self.user_list = Some(response?.users);
|
||||
self.task = None;
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddMember => return self.submit_add_member(),
|
||||
Msg::AddMemberResponse(response) => {
|
||||
response?;
|
||||
self.task = None;
|
||||
self.common.cancel_task();
|
||||
let user = self
|
||||
.selected_user
|
||||
.as_ref()
|
||||
.expect("Could not get selected user")
|
||||
.clone();
|
||||
// Remove the user from the dropdown.
|
||||
self.props.on_user_added_to_group.emit(user);
|
||||
self.common.on_user_added_to_group.emit(user);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_user.is_some();
|
||||
@@ -123,8 +82,38 @@ impl AddGroupMemberComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl AddGroupMemberComponent {
|
||||
fn get_user_list(&mut self) {
|
||||
self.common.call_graphql::<ListUserNames, _>(
|
||||
list_user_names::Variables { filters: None },
|
||||
Msg::UserListResponse,
|
||||
"Error trying to fetch user list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_member(&mut self) -> Result<bool> {
|
||||
let user_id = match self.selected_user.clone() {
|
||||
None => return Ok(false),
|
||||
Some(user) => user.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
add_user_to_group::Variables {
|
||||
user: user_id,
|
||||
group: self.common.group_id,
|
||||
},
|
||||
Msg::AddMemberResponse,
|
||||
"Error trying to initiate adding the user to a group",
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = self.props.users.iter().collect::<HashSet<_>>();
|
||||
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
|
||||
user_list
|
||||
.iter()
|
||||
.filter(|u| !user_groups.contains(u))
|
||||
@@ -136,32 +125,27 @@ impl AddGroupMemberComponent {
|
||||
impl Component for AddGroupMemberComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
link,
|
||||
props,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
user_list: None,
|
||||
selected_user: None,
|
||||
task: None,
|
||||
};
|
||||
res.get_user_list();
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.props.on_error.emit(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -176,7 +160,7 @@ impl Component for AddGroupMemberComponent {
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.link.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
{
|
||||
to_add_user_list
|
||||
.into_iter()
|
||||
@@ -188,8 +172,8 @@ impl Component for AddGroupMemberComponent {
|
||||
<div class="col-sm-1">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_user.is_none() || self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::SubmitAddMember)>
|
||||
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
||||
{"Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,12 @@ use crate::{
|
||||
select::{Select, SelectOption, SelectOptionProps},
|
||||
user_details::Group,
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use std::collections::HashSet;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -45,14 +41,11 @@ impl From<GroupListGroup> for Group {
|
||||
}
|
||||
|
||||
pub struct AddUserToGroupComponent {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The list of existing groups, initially not loaded.
|
||||
group_list: Option<Vec<Group>>,
|
||||
/// The currently selected group.
|
||||
selected_group: Option<Group>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -70,51 +63,17 @@ pub struct Props {
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
impl AddUserToGroupComponent {
|
||||
fn get_group_list(&mut self) {
|
||||
self.task = HostService::graphql_query::<GetGroupList>(
|
||||
get_group_list::Variables,
|
||||
self.link.callback(Msg::GroupListResponse),
|
||||
"Error trying to fetch group list",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn submit_add_group(&mut self) -> Result<bool> {
|
||||
let group_id = match &self.selected_group {
|
||||
None => return Ok(false),
|
||||
Some(group) => group.id,
|
||||
};
|
||||
self.task = HostService::graphql_query::<AddUserToGroup>(
|
||||
add_user_to_group::Variables {
|
||||
user: self.props.username.clone(),
|
||||
group: group_id,
|
||||
},
|
||||
self.link.callback(Msg::AddGroupResponse),
|
||||
"Error trying to initiate adding the user to a group",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupListResponse(response) => {
|
||||
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
||||
self.task = None;
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddGroup => return self.submit_add_group(),
|
||||
Msg::AddGroupResponse(response) => {
|
||||
response?;
|
||||
self.task = None;
|
||||
self.common.cancel_task();
|
||||
// Adding the user to the group succeeded, we're not in the process of adding a
|
||||
// group anymore.
|
||||
let group = self
|
||||
@@ -123,7 +82,7 @@ impl AddUserToGroupComponent {
|
||||
.expect("Could not get selected group")
|
||||
.clone();
|
||||
// Remove the group from the dropdown.
|
||||
self.props.on_user_added_to_group.emit(group);
|
||||
self.common.on_user_added_to_group.emit(group);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_group.is_some();
|
||||
@@ -137,8 +96,38 @@ impl AddUserToGroupComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl AddUserToGroupComponent {
|
||||
fn get_group_list(&mut self) {
|
||||
self.common.call_graphql::<GetGroupList, _>(
|
||||
get_group_list::Variables,
|
||||
Msg::GroupListResponse,
|
||||
"Error trying to fetch group list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_group(&mut self) -> Result<bool> {
|
||||
let group_id = match &self.selected_group {
|
||||
None => return Ok(false),
|
||||
Some(group) => group.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
add_user_to_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
group: group_id,
|
||||
},
|
||||
Msg::AddGroupResponse,
|
||||
"Error trying to initiate adding the user to a group",
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = self.props.groups.iter().collect::<HashSet<_>>();
|
||||
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
|
||||
group_list
|
||||
.iter()
|
||||
.filter(|g| !user_groups.contains(g))
|
||||
@@ -152,29 +141,24 @@ impl Component for AddUserToGroupComponent {
|
||||
type Properties = Props;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
link,
|
||||
props,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
group_list: None,
|
||||
selected_group: None,
|
||||
task: None,
|
||||
};
|
||||
res.get_group_list();
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.props.on_error.emit(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -189,7 +173,7 @@ impl Component for AddUserToGroupComponent {
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.link.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
{
|
||||
to_add_group_list
|
||||
.into_iter()
|
||||
@@ -201,8 +185,8 @@ impl Component for AddUserToGroupComponent {
|
||||
<div class="col-sm-1">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_group.is_none() || self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::SubmitAddGroup)>
|
||||
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
||||
{"Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::{
|
||||
group_table::GroupTable,
|
||||
login::LoginForm,
|
||||
logout::LogoutButton,
|
||||
reset_password_step1::ResetPasswordStep1Form,
|
||||
reset_password_step2::ResetPasswordStep2Form,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
user_details::UserDetails,
|
||||
user_table::UserTable,
|
||||
@@ -101,40 +103,7 @@ impl Component for App {
|
||||
<div class="row justify-content-center">
|
||||
<div class="shadow-sm py-3" style="max-width: 1000px">
|
||||
<Router<AppRoute>
|
||||
render = Router::render(move |switch: AppRoute| {
|
||||
match switch {
|
||||
AppRoute::Login => html! {
|
||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
||||
},
|
||||
AppRoute::CreateUser => html! {
|
||||
<CreateUserForm/>
|
||||
},
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<UserTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
|
||||
</div>
|
||||
},
|
||||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
|
||||
</div>
|
||||
},
|
||||
AppRoute::GroupDetails(group_id) => html! {
|
||||
<GroupDetails group_id=group_id />
|
||||
},
|
||||
AppRoute::UserDetails(username) => html! {
|
||||
<UserDetails username=username.clone() is_admin=is_admin />
|
||||
},
|
||||
AppRoute::ChangePassword(username) => html! {
|
||||
<ChangePasswordForm username=username.clone() is_admin=is_admin />
|
||||
}
|
||||
}
|
||||
})
|
||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +116,11 @@ impl App {
|
||||
fn get_redirect_route() -> Option<AppRoute> {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.is_empty() || current_route == "/" || current_route.contains("login") {
|
||||
if current_route.is_empty()
|
||||
|| current_route == "/"
|
||||
|| current_route.contains("login")
|
||||
|| current_route.contains("reset-password")
|
||||
{
|
||||
None
|
||||
} else {
|
||||
use yew_router::Switch;
|
||||
@@ -156,6 +129,11 @@ impl App {
|
||||
}
|
||||
|
||||
fn apply_initial_redirections(&mut self) {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.contains("reset-password") {
|
||||
return;
|
||||
}
|
||||
match &self.user_info {
|
||||
None => {
|
||||
self.route_dispatcher
|
||||
@@ -181,6 +159,47 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
|
||||
match switch {
|
||||
AppRoute::Login => html! {
|
||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
||||
},
|
||||
AppRoute::CreateUser => html! {
|
||||
<CreateUserForm/>
|
||||
},
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<UserTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
|
||||
</div>
|
||||
},
|
||||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
|
||||
</div>
|
||||
},
|
||||
AppRoute::GroupDetails(group_id) => html! {
|
||||
<GroupDetails group_id=group_id />
|
||||
},
|
||||
AppRoute::UserDetails(username) => html! {
|
||||
<UserDetails username=username is_admin=is_admin />
|
||||
},
|
||||
AppRoute::ChangePassword(username) => html! {
|
||||
<ChangePasswordForm username=username is_admin=is_admin />
|
||||
},
|
||||
AppRoute::StartResetPassword => html! {
|
||||
<ResetPasswordStep1Form />
|
||||
},
|
||||
AppRoute::FinishResetPassword(token) => html! {
|
||||
<ResetPasswordStep2Form token=token />
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view_banner(&self) -> Html {
|
||||
html! {
|
||||
<header class="p-3 mb-4 border-bottom shadow-sm">
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
infra::api::HostService,
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
@@ -58,13 +58,9 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
||||
}
|
||||
|
||||
pub struct ChangePasswordForm {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
error: Option<anyhow::Error>,
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
opaque_data: OpaqueData,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
@@ -83,25 +79,16 @@ pub enum Msg {
|
||||
RegistrationFinishResponse(Result<()>),
|
||||
}
|
||||
|
||||
impl ChangePasswordForm {
|
||||
fn call_backend<M, Req, C, Resp>(&mut self, method: M, req: Req, callback: C) -> Result<()>
|
||||
where
|
||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
||||
C: Fn(Resp) -> <Self as Component>::Message + 'static,
|
||||
{
|
||||
self.task = Some(method(req, self.link.callback(callback))?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::FormUpdate => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
if self.props.is_admin {
|
||||
self.handle_message(Msg::SubmitNewPassword)
|
||||
if self.common.is_admin {
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
} else {
|
||||
let old_password = self.form.model().old_password;
|
||||
if old_password.is_empty() {
|
||||
@@ -113,10 +100,10 @@ impl ChangePasswordForm {
|
||||
.context("Could not initialize login")?;
|
||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username: self.props.username.clone(),
|
||||
username: self.common.username.clone(),
|
||||
login_start_request: login_start_request.message,
|
||||
};
|
||||
self.call_backend(
|
||||
self.common.call_backend(
|
||||
HostService::login_start,
|
||||
req,
|
||||
Msg::AuthenticationStartResponse,
|
||||
@@ -142,7 +129,7 @@ impl ChangePasswordForm {
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
};
|
||||
self.handle_message(Msg::SubmitNewPassword)
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
}
|
||||
Msg::SubmitNewPassword => {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
@@ -151,11 +138,11 @@ impl ChangePasswordForm {
|
||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.props.username.clone(),
|
||||
username: self.common.username.clone(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||
self.call_backend(
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
Msg::RegistrationStartResponse,
|
||||
@@ -178,7 +165,7 @@ impl ChangePasswordForm {
|
||||
server_data: res.server_data,
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.call_backend(
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
Msg::RegistrationFinishResponse,
|
||||
@@ -189,11 +176,11 @@ impl ChangePasswordForm {
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.task = None;
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
AppRoute::UserDetails(self.props.username.clone()),
|
||||
AppRoute::UserDetails(self.common.username.clone()),
|
||||
)));
|
||||
}
|
||||
response?;
|
||||
@@ -201,6 +188,10 @@ impl ChangePasswordForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ChangePasswordForm {
|
||||
@@ -209,27 +200,15 @@ impl Component for ChangePasswordForm {
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
ChangePasswordForm {
|
||||
link,
|
||||
props,
|
||||
error: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: OpaqueData::None,
|
||||
task: None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_message(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -237,7 +216,7 @@ impl Component for ChangePasswordForm {
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let is_admin = self.props.is_admin;
|
||||
let is_admin = self.common.is_admin;
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
@@ -257,7 +236,7 @@ impl Component for ChangePasswordForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="current-password"
|
||||
oninput=self.link.callback(|_| Msg::FormUpdate) />
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("old_password")}
|
||||
</div>
|
||||
@@ -277,7 +256,7 @@ impl Component for ChangePasswordForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.link.callback(|_| Msg::FormUpdate) />
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
@@ -296,7 +275,7 @@ impl Component for ChangePasswordForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.link.callback(|_| Msg::FormUpdate) />
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
@@ -306,13 +285,13 @@ impl Component for ChangePasswordForm {
|
||||
<button
|
||||
class="btn btn-primary col-sm-1 col-form-label"
|
||||
type="submit"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.error {
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
@@ -323,7 +302,7 @@ impl Component for ChangePasswordForm {
|
||||
<div>
|
||||
<NavButton
|
||||
classes="btn btn-primary"
|
||||
route=AppRoute::UserDetails(self.props.username.clone())>
|
||||
route=AppRoute::UserDetails(self.common.username.clone())>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use crate::{components::router::AppRoute, infra::api::HostService};
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
@@ -20,12 +23,9 @@ use yew_router::{
|
||||
pub struct CreateGroup;
|
||||
|
||||
pub struct CreateGroupForm {
|
||||
link: ComponentLink<Self>,
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateGroupModel>,
|
||||
error: Option<anyhow::Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
@@ -40,7 +40,7 @@ pub enum Msg {
|
||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||
}
|
||||
|
||||
impl CreateGroupForm {
|
||||
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
@@ -52,11 +52,11 @@ impl CreateGroupForm {
|
||||
let req = create_group::Variables {
|
||||
name: model.groupname,
|
||||
};
|
||||
self.task = Some(HostService::graphql_query::<CreateGroup>(
|
||||
self.common.call_graphql::<CreateGroup, _>(
|
||||
req,
|
||||
self.link.callback(Msg::CreateGroupResponse),
|
||||
Msg::CreateGroupResponse,
|
||||
"Error trying to create group",
|
||||
)?);
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateGroupResponse(response) => {
|
||||
@@ -70,33 +70,26 @@ impl CreateGroupForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CreateGroupForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||
error: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -124,7 +117,7 @@ impl Component for CreateGroupForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="groupname"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("groupname")}
|
||||
</div>
|
||||
@@ -134,13 +127,13 @@ impl Component for CreateGroupForm {
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.error {
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use crate::{components::router::AppRoute, infra::api::HostService};
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{opaque, registration};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
@@ -21,12 +27,9 @@ use yew_router::{
|
||||
pub struct CreateUser;
|
||||
|
||||
pub struct CreateUserForm {
|
||||
link: ComponentLink<Self>,
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateUserModel>,
|
||||
error: Option<anyhow::Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
@@ -70,7 +73,7 @@ pub enum Msg {
|
||||
RegistrationFinishResponse(Result<()>),
|
||||
}
|
||||
|
||||
impl CreateUserForm {
|
||||
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
@@ -89,11 +92,11 @@ impl CreateUserForm {
|
||||
lastName: to_option(model.last_name),
|
||||
},
|
||||
};
|
||||
self.task = Some(HostService::graphql_query::<CreateUser>(
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
req,
|
||||
self.link.callback(Msg::CreateUserResponse),
|
||||
Msg::CreateUserResponse,
|
||||
"Error trying to create user",
|
||||
)?);
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateUserResponse(r) => {
|
||||
@@ -118,14 +121,11 @@ impl CreateUserForm {
|
||||
username: user_id,
|
||||
registration_start_request: message,
|
||||
};
|
||||
self.task = Some(
|
||||
HostService::register_start(
|
||||
req,
|
||||
self.link
|
||||
.callback_once(move |r| Msg::RegistrationStartResponse((state, r))),
|
||||
)
|
||||
.context("Error trying to create user")?,
|
||||
);
|
||||
self.common
|
||||
.call_backend(HostService::register_start, req, move |r| {
|
||||
Msg::RegistrationStartResponse((state, r))
|
||||
})
|
||||
.context("Error trying to create user")?;
|
||||
} else {
|
||||
self.update(Msg::SuccessfulCreation);
|
||||
}
|
||||
@@ -143,13 +143,13 @@ impl CreateUserForm {
|
||||
server_data: response.server_data,
|
||||
registration_upload: registration_upload.message,
|
||||
};
|
||||
self.task = Some(
|
||||
HostService::register_finish(
|
||||
self.common
|
||||
.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
self.link.callback(Msg::RegistrationFinishResponse),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
.context("Error trying to register user")?,
|
||||
);
|
||||
.context("Error trying to register user")?;
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
@@ -163,33 +163,26 @@ impl CreateUserForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CreateUserForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||
error: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -217,7 +210,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="username"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("username")}
|
||||
</div>
|
||||
@@ -237,7 +230,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="email"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
@@ -256,7 +249,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="display_name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
@@ -275,7 +268,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="first_name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
@@ -294,7 +287,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="last_name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
@@ -314,7 +307,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
@@ -334,7 +327,7 @@ impl Component for CreateUserForm {
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
@@ -343,14 +336,14 @@ impl Component for CreateUserForm {
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label mt-4"
|
||||
disabled=self.task.is_some()
|
||||
disabled=self.common.is_task_running()
|
||||
type="submit"
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.error {
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::{
|
||||
components::group_table::Group,
|
||||
infra::{api::HostService, modal::Modal},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
use yew::services::fetch::FetchTask;
|
||||
use yewtil::NeqAssign;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -18,11 +19,9 @@ use yewtil::NeqAssign;
|
||||
pub struct DeleteGroupQuery;
|
||||
|
||||
pub struct DeleteGroup {
|
||||
link: ComponentLink<Self>,
|
||||
props: DeleteGroupProps,
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
@@ -39,17 +38,51 @@ pub enum Msg {
|
||||
DeleteGroupResponse(Result<delete_group_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteGroup => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroup => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteGroupQuery, _>(
|
||||
delete_group_query::Variables {
|
||||
group_id: self.common.group.id,
|
||||
},
|
||||
Msg::DeleteGroupResponse,
|
||||
"Error trying to delete group",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
.on_group_deleted
|
||||
.emit(self.common.group.id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteGroup {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteGroupProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,39 +97,15 @@ impl Component for DeleteGroup {
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match msg {
|
||||
Msg::ClickedDeleteGroup => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroup => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.task = HostService::graphql_query::<DeleteGroupQuery>(
|
||||
delete_group_query::Variables {
|
||||
group_id: self.props.group.id,
|
||||
},
|
||||
self.link.callback(Msg::DeleteGroupResponse),
|
||||
"Error trying to delete group",
|
||||
)
|
||||
.map_err(|e| self.props.on_error.emit(e))
|
||||
.ok();
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupResponse(response) => {
|
||||
self.task = None;
|
||||
if let Err(e) = response {
|
||||
self.props.on_error.emit(e);
|
||||
} else {
|
||||
self.props.on_group_deleted.emit(self.props.group.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -104,8 +113,8 @@ impl Component for DeleteGroup {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::ClickedDeleteGroup)>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
@@ -119,7 +128,7 @@ impl DeleteGroup {
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteGroupModal".to_string() + &self.props.group.id.to_string()
|
||||
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteGroupModalLabel"
|
||||
aria-hidden="true"
|
||||
@@ -132,24 +141,24 @@ impl DeleteGroup {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.link.callback(|_| Msg::DismissModal) />
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete group "}
|
||||
<b>{&self.props.group.display_name}</b>{"?"}
|
||||
<b>{&self.common.group.display_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.link.callback(|_| Msg::DismissModal)>
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.link.callback(|_| Msg::ConfirmDeleteGroup)
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::infra::{api::HostService, modal::Modal};
|
||||
use crate::infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
use yew::services::fetch::FetchTask;
|
||||
use yewtil::NeqAssign;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -15,11 +16,9 @@ use yewtil::NeqAssign;
|
||||
pub struct DeleteUserQuery;
|
||||
|
||||
pub struct DeleteUser {
|
||||
link: ComponentLink<Self>,
|
||||
props: DeleteUserProps,
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
@@ -36,17 +35,51 @@ pub enum Msg {
|
||||
DeleteUserResponse(Result<delete_user_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteUser> for DeleteUser {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteUser => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUser => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteUserQuery, _>(
|
||||
delete_user_query::Variables {
|
||||
user: self.common.username.clone(),
|
||||
},
|
||||
Msg::DeleteUserResponse,
|
||||
"Error trying to delete user",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
.on_user_deleted
|
||||
.emit(self.common.username.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteUser {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteUserProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,39 +94,15 @@ impl Component for DeleteUser {
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match msg {
|
||||
Msg::ClickedDeleteUser => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUser => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.task = HostService::graphql_query::<DeleteUserQuery>(
|
||||
delete_user_query::Variables {
|
||||
user: self.props.username.clone(),
|
||||
},
|
||||
self.link.callback(Msg::DeleteUserResponse),
|
||||
"Error trying to delete user",
|
||||
)
|
||||
.map_err(|e| self.props.on_error.emit(e))
|
||||
.ok();
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserResponse(response) => {
|
||||
self.task = None;
|
||||
if let Err(e) = response {
|
||||
self.props.on_error.emit(e);
|
||||
} else {
|
||||
self.props.on_user_deleted.emit(self.props.username.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
@@ -101,8 +110,8 @@ impl Component for DeleteUser {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::ClickedDeleteUser)>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
@@ -116,7 +125,7 @@ impl DeleteUser {
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteUserModal".to_string() + &self.props.username
|
||||
id="deleteUserModal".to_string() + &self.common.username
|
||||
tabindex="-1"
|
||||
//role="dialog"
|
||||
aria-labelledby="deleteUserModalLabel"
|
||||
@@ -130,24 +139,24 @@ impl DeleteUser {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.link.callback(|_| Msg::DismissModal) />
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete user "}
|
||||
<b>{&self.props.username}</b>{"?"}
|
||||
<b>{&self.common.username}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.link.callback(|_| Msg::DismissModal)>
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.link.callback(|_| Msg::ConfirmDeleteUser)
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,11 @@ use crate::{
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -27,15 +24,10 @@ pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
||||
pub type AddGroupMemberUser = add_group_member::User;
|
||||
|
||||
pub struct GroupDetails {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
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: Option<Group>,
|
||||
/// Error message displayed to the user.
|
||||
error: Option<Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -55,45 +47,13 @@ pub struct Props {
|
||||
|
||||
impl GroupDetails {
|
||||
fn get_group_details(&mut self) {
|
||||
self._task = HostService::graphql_query::<GetGroupDetails>(
|
||||
self.common.call_graphql::<GetGroupDetails, _>(
|
||||
get_group_details::Variables {
|
||||
id: self.props.group_id,
|
||||
id: self.common.group_id,
|
||||
},
|
||||
self.link.callback(Msg::GroupDetailsResponse),
|
||||
Msg::GroupDetailsResponse,
|
||||
"Error trying to fetch group details",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
Err(e) => {
|
||||
self.group = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(user) => {
|
||||
self.group.as_mut().unwrap().users.push(User {
|
||||
id: user.id,
|
||||
display_name: user.display_name,
|
||||
});
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||
self.group
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.users
|
||||
.retain(|u| u.id != user_id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
);
|
||||
}
|
||||
|
||||
fn view_messages(&self, error: &Option<Error>) -> Html {
|
||||
@@ -124,8 +84,8 @@ impl GroupDetails {
|
||||
<RemoveUserFromGroupComponent
|
||||
username=user_id
|
||||
group_id=g.id
|
||||
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -174,38 +134,60 @@ impl GroupDetails {
|
||||
<AddGroupMemberComponent
|
||||
group_id=g.id
|
||||
users=users
|
||||
on_error=self.link.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/>
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
Err(e) => {
|
||||
self.group = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(user) => {
|
||||
self.group.as_mut().unwrap().users.push(User {
|
||||
id: user.id,
|
||||
display_name: user.display_name,
|
||||
});
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||
self.group
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.users
|
||||
.retain(|u| u.id != user_id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GroupDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
link,
|
||||
props,
|
||||
_task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
group: None,
|
||||
error: None,
|
||||
};
|
||||
table.get_group_details();
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -213,7 +195,7 @@ impl Component for GroupDetails {
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
match (&self.group, &self.error) {
|
||||
match (&self.group, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some(u), error) => {
|
||||
|
||||
@@ -3,12 +3,11 @@ use crate::{
|
||||
delete_group::DeleteGroup,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -24,11 +23,8 @@ use get_group_list::ResponseData;
|
||||
pub type Group = get_group_list::GetGroupListGroups;
|
||||
|
||||
pub struct GroupTable {
|
||||
link: ComponentLink<Self>,
|
||||
common: CommonComponentParts<Self>,
|
||||
groups: Option<Vec<Group>>,
|
||||
error: Option<Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -37,18 +33,24 @@ pub enum Msg {
|
||||
OnError(Error),
|
||||
}
|
||||
|
||||
impl GroupTable {
|
||||
fn get_groups(&mut self) {
|
||||
self._task = HostService::graphql_query::<GetGroupList>(
|
||||
get_group_list::Variables {},
|
||||
self.link.callback(Msg::ListGroupsResponse),
|
||||
"Error trying to fetch groups",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
impl CommonComponent<GroupTable> for GroupTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListGroupsResponse(groups) => {
|
||||
self.groups = Some(groups?.groups.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::OnError(e) => Err(e),
|
||||
Msg::OnGroupDeleted(group_id) => {
|
||||
debug_assert!(self.groups.is_some());
|
||||
self.groups.as_mut().unwrap().retain(|u| u.id != group_id);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,27 +58,21 @@ impl Component for GroupTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = GroupTable {
|
||||
link,
|
||||
_task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
groups: None,
|
||||
error: None,
|
||||
};
|
||||
table.get_groups();
|
||||
table.common.call_graphql::<GetGroupList, _>(
|
||||
get_group_list::Variables {},
|
||||
Msg::ListGroupsResponse,
|
||||
"Error trying to fetch groups",
|
||||
);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -94,21 +90,6 @@ impl Component for GroupTable {
|
||||
}
|
||||
|
||||
impl GroupTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListGroupsResponse(groups) => {
|
||||
self.groups = Some(groups?.groups.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::OnError(e) => Err(e),
|
||||
Msg::OnGroupDeleted(group_id) => {
|
||||
debug_assert!(self.groups.is_some());
|
||||
self.groups.as_mut().unwrap().retain(|u| u.id != group_id);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_groups(&self) -> Html {
|
||||
let make_table = |groups: &Vec<Group>| {
|
||||
html! {
|
||||
@@ -144,15 +125,15 @@ impl GroupTable {
|
||||
<td>
|
||||
<DeleteGroup
|
||||
group=group.clone()
|
||||
on_group_deleted=self.link.callback(Msg::OnGroupDeleted)
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_errors(&self) -> Html {
|
||||
match &self.error {
|
||||
match &self.common.error {
|
||||
None => html! {},
|
||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
use crate::infra::api::HostService;
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
pub struct LoginForm {
|
||||
link: ComponentLink<Self>,
|
||||
on_logged_in: Callback<(String, bool)>,
|
||||
error: Option<anyhow::Error>,
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
@@ -44,8 +43,8 @@ pub enum Msg {
|
||||
AuthenticationFinishResponse(Result<(String, bool)>),
|
||||
}
|
||||
|
||||
impl LoginForm {
|
||||
fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
impl CommonComponent<LoginForm> for LoginForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@@ -61,11 +60,10 @@ impl LoginForm {
|
||||
username,
|
||||
login_start_request: message,
|
||||
};
|
||||
self.task = Some(HostService::login_start(
|
||||
req,
|
||||
self.link
|
||||
.callback_once(move |r| Msg::AuthenticationStartResponse((state, r))),
|
||||
)?);
|
||||
self.common
|
||||
.call_backend(HostService::login_start, req, move |r| {
|
||||
Msg::AuthenticationStartResponse((state, r))
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
||||
@@ -77,7 +75,8 @@ impl LoginForm {
|
||||
// Common error, we want to print a full error to the console but only a
|
||||
// simple one to the user.
|
||||
ConsoleService::error(&format!("Invalid username or password: {}", e));
|
||||
self.error = Some(anyhow!("Invalid username or password"));
|
||||
self.common.error = Some(anyhow!("Invalid username or password"));
|
||||
self.common.cancel_task();
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(l) => l,
|
||||
@@ -86,20 +85,26 @@ impl LoginForm {
|
||||
server_data: res.server_data,
|
||||
credential_finalization: login_finish.message,
|
||||
};
|
||||
self.task = Some(HostService::login_finish(
|
||||
self.common.call_backend(
|
||||
HostService::login_finish,
|
||||
req,
|
||||
self.link.callback_once(Msg::AuthenticationFinishResponse),
|
||||
)?);
|
||||
Msg::AuthenticationFinishResponse,
|
||||
)?;
|
||||
Ok(false)
|
||||
}
|
||||
Msg::AuthenticationFinishResponse(user_info) => {
|
||||
self.task = None;
|
||||
self.on_logged_in
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
.on_logged_in
|
||||
.emit(user_info.context("Could not log in")?);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for LoginForm {
|
||||
@@ -108,25 +113,13 @@ impl Component for LoginForm {
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
LoginForm {
|
||||
link,
|
||||
on_logged_in: props.on_logged_in,
|
||||
error: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_message(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -152,7 +145,7 @@ impl Component for LoginForm {
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
@@ -170,17 +163,23 @@ impl Component for LoginForm {
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Login"}
|
||||
</button>
|
||||
<NavButton
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::StartResetPassword>
|
||||
{"Forgot your password?"}
|
||||
</NavButton>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.error {
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::infra::{api::HostService, cookies::delete_cookie};
|
||||
use crate::infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
cookies::delete_cookie,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
|
||||
pub struct LogoutButton {
|
||||
link: ComponentLink<Self>,
|
||||
on_logged_out: Callback<()>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
common: CommonComponentParts<Self>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
@@ -20,43 +20,39 @@ pub enum Msg {
|
||||
LogoutCompleted(Result<()>),
|
||||
}
|
||||
|
||||
impl CommonComponent<LogoutButton> for LogoutButton {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::LogoutRequested => {
|
||||
self.common
|
||||
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
|
||||
}
|
||||
Msg::LogoutCompleted(res) => {
|
||||
res?;
|
||||
delete_cookie("user_id")?;
|
||||
self.common.on_logged_out.emit(());
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for LogoutButton {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
LogoutButton {
|
||||
link,
|
||||
on_logged_out: props.on_logged_out,
|
||||
_task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match msg {
|
||||
Msg::LogoutRequested => {
|
||||
match HostService::logout(self.link.callback(Msg::LogoutCompleted)) {
|
||||
Ok(task) => self._task = Some(task),
|
||||
Err(e) => ConsoleService::error(&e.to_string()),
|
||||
};
|
||||
false
|
||||
}
|
||||
Msg::LogoutCompleted(res) => {
|
||||
if let Err(e) = res {
|
||||
ConsoleService::error(&e.to_string());
|
||||
}
|
||||
match delete_cookie("user_id") {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
false
|
||||
}
|
||||
Ok(()) => {
|
||||
self.on_logged_out.emit(());
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -67,7 +63,7 @@ impl Component for LogoutButton {
|
||||
html! {
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick=self.link.callback(|_| Msg::LogoutRequested)>
|
||||
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
||||
{"Logout"}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ pub mod group_table;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod remove_user_from_group;
|
||||
pub mod reset_password_step1;
|
||||
pub mod reset_password_step2;
|
||||
pub mod router;
|
||||
pub mod select;
|
||||
pub mod user_details;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use crate::infra::api::HostService;
|
||||
use crate::infra::common_component::{CommonComponent, CommonComponentParts};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -17,10 +14,7 @@ use yew::{
|
||||
pub struct RemoveUserFromGroup;
|
||||
|
||||
pub struct RemoveUserFromGroupComponent {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
// Used to keep the request alive long enough.
|
||||
task: Option<FetchTask>,
|
||||
common: CommonComponentParts<Self>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
@@ -36,38 +30,37 @@ pub enum Msg {
|
||||
RemoveGroupResponse(Result<remove_user_from_group::ResponseData>),
|
||||
}
|
||||
|
||||
impl RemoveUserFromGroupComponent {
|
||||
fn submit_remove_group(&mut self) -> Result<bool> {
|
||||
let group = self.props.group_id;
|
||||
self.task = HostService::graphql_query::<RemoveUserFromGroup>(
|
||||
remove_user_from_group::Variables {
|
||||
user: self.props.username.clone(),
|
||||
group,
|
||||
},
|
||||
self.link.callback(Msg::RemoveGroupResponse),
|
||||
"Error trying to initiate removing the user from a group",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::SubmitRemoveGroup => return self.submit_remove_group(),
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
||||
Msg::RemoveGroupResponse(response) => {
|
||||
response?;
|
||||
self.task = None;
|
||||
self.props
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
.on_user_removed_from_group
|
||||
.emit((self.props.username.clone(), self.props.group_id));
|
||||
.emit((self.common.username.clone(), self.common.group_id));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoveUserFromGroupComponent {
|
||||
fn submit_remove_group(&mut self) {
|
||||
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
||||
remove_user_from_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
group: self.common.group_id,
|
||||
},
|
||||
Msg::RemoveGroupResponse,
|
||||
"Error trying to initiate removing the user from a group",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for RemoveUserFromGroupComponent {
|
||||
@@ -76,21 +69,16 @@ impl Component for RemoveUserFromGroupComponent {
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
self.task = None;
|
||||
self.props.on_error.emit(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -101,8 +89,8 @@ impl Component for RemoveUserFromGroupComponent {
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|_| Msg::SubmitRemoveGroup)>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||
</button>
|
||||
}
|
||||
|
||||
140
app/src/components/reset_password_step1.rs
Normal file
140
app/src/components/reset_password_step1.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
pub struct ResetPasswordStep1Form {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
just_succeeded: bool,
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 1, message = "Missing username"))]
|
||||
username: String,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
Submit,
|
||||
PasswordResetResponse(Result<()>),
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let FormModel { username } = self.form.model();
|
||||
self.common.call_backend(
|
||||
HostService::reset_password_step1,
|
||||
&username,
|
||||
Msg::PasswordResetResponse,
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
Msg::PasswordResetResponse(response) => {
|
||||
response?;
|
||||
self.just_succeeded = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ResetPasswordStep1Form {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
ResetPasswordStep1Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
just_succeeded: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.just_succeeded = false;
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
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>
|
||||
</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=self.common.callback(|_| Msg::Update) />
|
||||
</div>
|
||||
{ if self.just_succeeded {
|
||||
html! {
|
||||
{"A reset token has been sent to your email."}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Reset password"}
|
||||
</button>
|
||||
<NavButton
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::Login>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
232
app/src/components/reset_password_step2.rs
Normal file
232
app/src/components/reset_password_step2.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
|
||||
password: String,
|
||||
#[validate(must_match(other = "password", message = "Passwords must match"))]
|
||||
confirm_password: String,
|
||||
}
|
||||
|
||||
pub struct ResetPasswordStep2Form {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
username: Option<String>,
|
||||
opaque_data: Option<opaque::client::registration::ClientRegistration>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct Props {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ValidateTokenResponse(Result<String>),
|
||||
FormUpdate,
|
||||
Submit,
|
||||
RegistrationStartResponse(Result<Box<registration::ServerRegistrationStartResponse>>),
|
||||
RegistrationFinishResponse(Result<()>),
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ValidateTokenResponse(response) => {
|
||||
self.username = Some(response?);
|
||||
self.common.cancel_task();
|
||||
Ok(true)
|
||||
}
|
||||
Msg::FormUpdate => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let new_password = self.form.model().password;
|
||||
let registration_start_request =
|
||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.username.clone().unwrap(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = Some(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
let res = res.context("Could not initiate password change")?;
|
||||
let registration = self.opaque_data.take().expect("Missing registration data");
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let registration_finish = opaque::client::registration::finish_registration(
|
||||
registration,
|
||||
res.registration_response,
|
||||
&mut rng,
|
||||
)
|
||||
.context("Error during password change")?;
|
||||
let req = registration::ClientRegistrationFinishRequest {
|
||||
server_data: res.server_data,
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
Msg::RegistrationFinishResponse,
|
||||
)?;
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ResetPasswordStep2Form {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut component = ResetPasswordStep2Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
username: None,
|
||||
};
|
||||
let token = component.common.token.clone();
|
||||
component
|
||||
.common
|
||||
.call_backend(
|
||||
HostService::reset_password_step2,
|
||||
&token,
|
||||
Msg::ValidateTokenResponse,
|
||||
)
|
||||
.unwrap();
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
match (&self.username, &self.common.error) {
|
||||
(None, None) => {
|
||||
return html! {
|
||||
{"Validating token"}
|
||||
}
|
||||
}
|
||||
(None, Some(e)) => {
|
||||
return html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
<h2>{"Reset your password"}</h2>
|
||||
<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=self.common.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=self.common.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=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ use yew_router::{
|
||||
pub enum AppRoute {
|
||||
#[to = "/login"]
|
||||
Login,
|
||||
#[to = "/reset-password/step1"]
|
||||
StartResetPassword,
|
||||
#[to = "/reset-password/step2/{token}"]
|
||||
FinishResetPassword(String),
|
||||
#[to = "/users/create"]
|
||||
CreateUser,
|
||||
#[to = "/users"]
|
||||
|
||||
@@ -5,14 +5,11 @@ use crate::{
|
||||
router::{AppRoute, Link, NavButton},
|
||||
user_details_form::UserDetailsForm,
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -27,15 +24,10 @@ pub type User = get_user_details::GetUserDetailsUser;
|
||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||
|
||||
pub struct UserDetails {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
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: Option<User>,
|
||||
/// Error message displayed to the user.
|
||||
error: Option<Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -54,22 +46,7 @@ pub struct Props {
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn get_user_details(&mut self) {
|
||||
self._task = HostService::graphql_query::<GetUserDetails>(
|
||||
get_user_details::Variables {
|
||||
id: self.props.username.clone(),
|
||||
},
|
||||
self.link.callback(Msg::UserDetailsResponse),
|
||||
"Error trying to fetch user details",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetails> for UserDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserDetailsResponse(response) => match response {
|
||||
@@ -94,6 +71,22 @@ impl UserDetails {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn get_user_details(&mut self) {
|
||||
self.common.call_graphql::<GetUserDetails, _>(
|
||||
get_user_details::Variables {
|
||||
id: self.common.username.clone(),
|
||||
},
|
||||
Msg::UserDetailsResponse,
|
||||
"Error trying to fetch user details",
|
||||
);
|
||||
}
|
||||
|
||||
fn view_messages(&self, error: &Option<Error>) -> Html {
|
||||
if let Some(e) = error {
|
||||
html! {
|
||||
@@ -111,7 +104,7 @@ impl UserDetails {
|
||||
let display_name = group.display_name.clone();
|
||||
html! {
|
||||
<tr key="groupRow_".to_string() + &display_name>
|
||||
{if self.props.is_admin { html! {
|
||||
{if self.common.is_admin { html! {
|
||||
<>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
@@ -122,8 +115,8 @@ impl UserDetails {
|
||||
<RemoveUserFromGroupComponent
|
||||
username=u.id.clone()
|
||||
group_id=group.id
|
||||
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
</td>
|
||||
</>
|
||||
} } else { html! {
|
||||
@@ -140,7 +133,7 @@ impl UserDetails {
|
||||
<thead>
|
||||
<tr key="headerRow">
|
||||
<th>{"Group"}</th>
|
||||
{ if self.props.is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -161,13 +154,13 @@ impl UserDetails {
|
||||
}
|
||||
|
||||
fn view_add_group_button(&self, u: &User) -> Html {
|
||||
if self.props.is_admin {
|
||||
if self.common.is_admin {
|
||||
html! {
|
||||
<AddUserToGroupComponent
|
||||
username=u.id.clone()
|
||||
groups=u.groups.clone()
|
||||
on_error=self.link.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/>
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
@@ -181,26 +174,15 @@ impl Component for UserDetails {
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
link,
|
||||
props,
|
||||
_task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
user: None,
|
||||
error: None,
|
||||
};
|
||||
table.get_user_details();
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -208,7 +190,7 @@ impl Component for UserDetails {
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
match (&self.user, &self.error) {
|
||||
match (&self.user, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some(u), error) => {
|
||||
@@ -217,7 +199,7 @@ impl Component for UserDetails {
|
||||
<h3>{u.id.to_string()}</h3>
|
||||
<UserDetailsForm
|
||||
user=u.clone()
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
<div class="row justify-content-center">
|
||||
<NavButton
|
||||
route=AppRoute::ChangePassword(u.id.clone())
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::{components::user_details::User, infra::api::HostService};
|
||||
use crate::{
|
||||
components::user_details::User,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
/// The fields of the form, with the editable details and the constraints.
|
||||
@@ -32,12 +32,10 @@ pub struct UpdateUser;
|
||||
|
||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||
pub struct UserDetailsForm {
|
||||
link: ComponentLink<Self>,
|
||||
props: Props,
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<UserModel>,
|
||||
/// True if we just successfully updated the user, to display a success message.
|
||||
just_updated: bool,
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -57,6 +55,20 @@ pub struct Props {
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for UserDetailsForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
@@ -69,25 +81,19 @@ impl Component for UserDetailsForm {
|
||||
last_name: props.user.last_name.clone(),
|
||||
};
|
||||
Self {
|
||||
link,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: yew_form::Form::new(model),
|
||||
props,
|
||||
just_updated: false,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.just_updated = false;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.props.on_error.emit(e);
|
||||
self.task = None;
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -105,7 +111,7 @@ impl Component for UserDetailsForm {
|
||||
{"User ID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="userId" class="form-constrol-static">{&self.props.user.id}</span>
|
||||
<span id="userId" class="form-constrol-static">{&self.common.user.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
@@ -121,7 +127,7 @@ impl Component for UserDetailsForm {
|
||||
form=&self.form
|
||||
field_name="email"
|
||||
autocomplete="email"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
@@ -140,7 +146,7 @@ impl Component for UserDetailsForm {
|
||||
form=&self.form
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
@@ -157,7 +163,7 @@ impl Component for UserDetailsForm {
|
||||
form=&self.form
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
@@ -174,7 +180,7 @@ impl Component for UserDetailsForm {
|
||||
form=&self.form
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput=self.link.callback(|_| Msg::Update) />
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
@@ -186,15 +192,15 @@ impl Component for UserDetailsForm {
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{&self.props.user.creation_date.date().naive_local()}</span>
|
||||
<span id="creationDate" class="form-constrol-static">{&self.common.user.creation_date.date().naive_local()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
disabled=self.task.is_some()
|
||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
||||
{"Update"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -208,21 +214,13 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
|
||||
impl UserDetailsForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
||||
if !self.form.validate() {
|
||||
bail!("Invalid inputs");
|
||||
}
|
||||
let base_user = &self.props.user;
|
||||
let base_user = &self.common.user;
|
||||
let mut user_input = update_user::UpdateUserInput {
|
||||
id: self.props.user.id.clone(),
|
||||
id: self.common.user.id.clone(),
|
||||
email: None,
|
||||
displayName: None,
|
||||
firstName: None,
|
||||
@@ -248,28 +246,28 @@ impl UserDetailsForm {
|
||||
return Ok(false);
|
||||
}
|
||||
let req = update_user::Variables { user: user_input };
|
||||
self.task = Some(HostService::graphql_query::<UpdateUser>(
|
||||
self.common.call_graphql::<UpdateUser, _>(
|
||||
req,
|
||||
self.link.callback(Msg::UserUpdated),
|
||||
Msg::UserUpdated,
|
||||
"Error trying to update user",
|
||||
)?);
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||
self.task = None;
|
||||
self.common.cancel_task();
|
||||
match r {
|
||||
Err(e) => return Err(e),
|
||||
Ok(_) => {
|
||||
let model = self.form.model();
|
||||
self.props.user = User {
|
||||
id: self.props.user.id.clone(),
|
||||
self.common.user = User {
|
||||
id: self.common.user.id.clone(),
|
||||
email: model.email,
|
||||
display_name: model.display_name,
|
||||
first_name: model.first_name,
|
||||
last_name: model.last_name,
|
||||
creation_date: self.props.user.creation_date,
|
||||
groups: self.props.user.groups.clone(),
|
||||
creation_date: self.common.user.creation_date,
|
||||
groups: self.common.user.groups.clone(),
|
||||
};
|
||||
self.just_updated = true;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ use crate::{
|
||||
delete_user::DeleteUser,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::api::HostService,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -24,11 +23,8 @@ use list_users_query::{RequestFilter, ResponseData};
|
||||
type User = list_users_query::ListUsersQueryUsers;
|
||||
|
||||
pub struct UserTable {
|
||||
link: ComponentLink<Self>,
|
||||
common: CommonComponentParts<Self>,
|
||||
users: Option<Vec<User>>,
|
||||
error: Option<Error>,
|
||||
// Used to keep the request alive long enough.
|
||||
_task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -37,18 +33,34 @@ pub enum Msg {
|
||||
OnError(Error),
|
||||
}
|
||||
|
||||
impl CommonComponent<UserTable> for UserTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListUsersResponse(users) => {
|
||||
self.users = Some(users?.users.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::OnError(e) => Err(e),
|
||||
Msg::OnUserDeleted(user_id) => {
|
||||
debug_assert!(self.users.is_some());
|
||||
self.users.as_mut().unwrap().retain(|u| u.id != user_id);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
||||
self._task = HostService::graphql_query::<ListUsersQuery>(
|
||||
self.common.call_graphql::<ListUsersQuery, _>(
|
||||
list_users_query::Variables { filters: req },
|
||||
self.link.callback(Msg::ListUsersResponse),
|
||||
Msg::ListUsersResponse,
|
||||
"Error trying to fetch users",
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,27 +68,17 @@ impl Component for UserTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut table = UserTable {
|
||||
link,
|
||||
_task: None,
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
users: None,
|
||||
error: None,
|
||||
};
|
||||
table.get_users(None);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
self.error = None;
|
||||
match self.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
self.error = Some(e);
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
@@ -94,21 +96,6 @@ impl Component for UserTable {
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListUsersResponse(users) => {
|
||||
self.users = Some(users?.users.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::OnError(e) => Err(e),
|
||||
Msg::OnUserDeleted(user_id) => {
|
||||
debug_assert!(self.users.is_some());
|
||||
self.users.as_mut().unwrap().retain(|u| u.id != user_id);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_users(&self) -> Html {
|
||||
let make_table = |users: &Vec<User>| {
|
||||
html! {
|
||||
@@ -150,15 +137,15 @@ impl UserTable {
|
||||
<td>
|
||||
<DeleteUser
|
||||
username=user.id.clone()
|
||||
on_user_deleted=self.link.callback(Msg::OnUserDeleted)
|
||||
on_error=self.link.callback(Msg::OnError)/>
|
||||
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_errors(&self) -> Html {
|
||||
match &self.error {
|
||||
match &self.common.error {
|
||||
None => html! {},
|
||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
|
||||
@@ -223,7 +223,8 @@ impl HostService {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn logout(callback: Callback<Result<()>>) -> Result<FetchTask> {
|
||||
// The `_request` parameter is to make it the same shape as the other functions.
|
||||
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/logout",
|
||||
yew::format::Nothing,
|
||||
@@ -231,4 +232,28 @@ impl HostService {
|
||||
"Could not logout",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reset_password_step1(
|
||||
username: &str,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&format!("/auth/reset/step1/{}", username),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
"Could not initiate password reset",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reset_password_step2(
|
||||
token: &str,
|
||||
callback: Callback<Result<String>>,
|
||||
) -> Result<FetchTask> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("/auth/reset/step2/{}", token),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
"Could not validate token",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
175
app/src/infra/common_component.rs
Normal file
175
app/src/infra/common_component.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! Common Component module.
|
||||
//! This is used to factor out some common functionality that is recurrent in modules all over the
|
||||
//! application. In particular:
|
||||
//! - error handling
|
||||
//! - task handling
|
||||
//! - storing props
|
||||
//!
|
||||
//! The pattern used is the
|
||||
//! [CRTP](https://en.wikipedia.org/wiki/Curiously_recurring_template_pattern) pattern: The
|
||||
//! [`CommonComponent`] trait must be implemented with `Self` as the parameter, e.g.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! struct MyComponent;
|
||||
//! impl CommonComponent<MyComponent> for MyComponent { ... }
|
||||
//! ```
|
||||
//!
|
||||
//! The component should also have a `CommonComponentParts<Self>` as a field, usually named
|
||||
//! `common`.
|
||||
//!
|
||||
//! Then the [`yew::prelude::Component::update`] method can delegate to
|
||||
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
||||
//! take care of error and task handling.
|
||||
|
||||
use crate::infra::api::HostService;
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
|
||||
/// Trait required for common components.
|
||||
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||
/// Handle the incoming message. If an error is returned here, any running task will be
|
||||
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
||||
/// component will be refreshed.
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
|
||||
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||
}
|
||||
|
||||
/// Structure that contains the common parts needed by most components.
|
||||
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
||||
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
||||
link: ComponentLink<C>,
|
||||
pub props: <C as Component>::Properties,
|
||||
pub error: Option<Error>,
|
||||
task: Option<FetchTask>,
|
||||
}
|
||||
|
||||
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
/// Whether there is a currently running task in the background.
|
||||
pub fn is_task_running(&self) -> bool {
|
||||
self.task.is_some()
|
||||
}
|
||||
|
||||
/// Cancel any background task.
|
||||
pub fn cancel_task(&mut self) {
|
||||
self.task = None;
|
||||
}
|
||||
|
||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
error: None,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
||||
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
||||
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
|
||||
com.mut_common().error = None;
|
||||
match com.handle_msg(msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
com.mut_common().error = Some(e);
|
||||
com.mut_common().cancel_task();
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as above, but the resulting error is instead passed to the reporting function.
|
||||
pub fn update_and_report_error(
|
||||
com: &mut C,
|
||||
msg: <C as Component>::Message,
|
||||
report_fn: Callback<Error>,
|
||||
) -> ShouldRender {
|
||||
let should_render = Self::update(com, msg);
|
||||
com.mut_common()
|
||||
.error
|
||||
.take()
|
||||
.map(|e| {
|
||||
report_fn.emit(e);
|
||||
true
|
||||
})
|
||||
.unwrap_or(should_render)
|
||||
}
|
||||
|
||||
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
|
||||
/// properties have changed and return whether the component should update.
|
||||
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
|
||||
where
|
||||
<C as yew::Component>::Properties: std::cmp::PartialEq,
|
||||
{
|
||||
self.props.neq_assign(props)
|
||||
}
|
||||
|
||||
/// Create a callback from the link.
|
||||
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
|
||||
where
|
||||
M: Into<C::Message>,
|
||||
F: Fn(IN) -> M + 'static,
|
||||
{
|
||||
self.link.callback(function)
|
||||
}
|
||||
|
||||
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
||||
/// result. Returns whether _starting the call_ failed.
|
||||
pub fn call_backend<M, Req, Cb, Resp>(
|
||||
&mut self,
|
||||
method: M,
|
||||
req: Req,
|
||||
callback: Cb,
|
||||
) -> Result<()>
|
||||
where
|
||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
||||
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = Some(method(req, self.link.callback_once(callback))?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call the backend with a GraphQL query.
|
||||
///
|
||||
/// `EnumCallback` should usually be left as `_`.
|
||||
pub fn call_graphql<QueryType, EnumCallback>(
|
||||
&mut self,
|
||||
variables: QueryType::Variables,
|
||||
enum_callback: EnumCallback,
|
||||
error_message: &'static str,
|
||||
) where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = HostService::graphql_query::<QueryType>(
|
||||
variables,
|
||||
self.link.callback(enum_callback),
|
||||
error_message,
|
||||
)
|
||||
.map_err::<(), _>(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
self.error = Some(e);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
|
||||
type Target = <C as Component>::Properties;
|
||||
|
||||
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
|
||||
&self.props
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
|
||||
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
|
||||
&mut self.props
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod common_component;
|
||||
pub mod cookies;
|
||||
pub mod graphql;
|
||||
pub mod modal;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![recursion_limit = "256"]
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::nonstandard_macro_braces)]
|
||||
pub mod components;
|
||||
pub mod infra;
|
||||
|
||||
84
architecture.md
Normal file
84
architecture.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Architecture
|
||||
|
||||
The server is entirely written in Rust, using [actix](https://actix.rs) for the
|
||||
backend and [yew](https://yew.rs) for the frontend.
|
||||
|
||||
Backend:
|
||||
* Listens on a port for LDAP protocol.
|
||||
* Only a small, read-only subset of the LDAP protocol is supported.
|
||||
* An extension to allow resetting the password through LDAP will be added.
|
||||
* Listens on another port for HTTP traffic.
|
||||
* The authentication API, based on JWTs, is under "/auth".
|
||||
* The user management API is a GraphQL API under "/api/graphql". The schema
|
||||
is defined in `schema.graphql`.
|
||||
* The static frontend files are served by this port too.
|
||||
|
||||
Note that secure protocols (LDAPS, HTTPS) are currently not supported. This can
|
||||
be worked around by using a reverse proxy in front of the server (for the HTTP
|
||||
API) that wraps/unwraps the HTTPS messages, or only open the service to
|
||||
localhost or other trusted docker containers (for the LDAP API).
|
||||
|
||||
Frontend:
|
||||
* User management UI.
|
||||
* Written in Rust compiled to WASM as an SPA with the Yew library.
|
||||
* Based on components, with a React-like organization.
|
||||
|
||||
Data storage:
|
||||
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
|
||||
* Currently only SQLite is supported (see
|
||||
https://github.com/launchbadge/sqlx/issues/1225 for what blocks us from
|
||||
supporting more SQL backends).
|
||||
|
||||
### Code organization
|
||||
|
||||
* `auth/`: Contains the shared structures needed for authentication, the
|
||||
interface between front and back-end. In particular, it contains the OPAQUE
|
||||
structures and the JWT format.
|
||||
* `app/`: The frontend.
|
||||
* `src/components`: The elements containing the business and display logic of
|
||||
the various pages and their components.
|
||||
* `src/infra`: Various tools and utilities.
|
||||
* `server/`: The backend.
|
||||
* `src/domain/`: Domain-specific logic: users, groups, checking passwords...
|
||||
* `src/infra/`: API, both GraphQL and LDAP
|
||||
|
||||
## Authentication
|
||||
|
||||
### Passwords
|
||||
|
||||
Passwords are hashed using Argon2, the state of the art in terms of password
|
||||
storage. They are hashed using a secret provided in the configuration (which
|
||||
can be given as environment variable or command line argument as well): this
|
||||
should be kept secret and shouldn't change (it would invalidate all passwords).
|
||||
|
||||
Authentication is done via the OPAQUE protocol, meaning that the passwords are
|
||||
never sent to the server, but instead the client proves that they know the
|
||||
correct password (zero-knowledge proof). This is likely overkill, especially
|
||||
considered that the LDAP interface requires sending the password to the server,
|
||||
but it's one less potential flaw (especially since the LDAP interface can be
|
||||
restricted to an internal docker-only network while the web app is exposed to
|
||||
the Internet).
|
||||
|
||||
### JWTs and refresh tokens
|
||||
|
||||
When logging in for the first time, users are provided with a refresh token
|
||||
that gets stored in an HTTP-only cookie, valid for 30 days. They can use this
|
||||
token to get a JWT to get access to various servers: the JWT lists the groups
|
||||
the user belongs to. To simplify the setup, there is a single JWT secret that
|
||||
should be shared between the authentication server and the application servers;
|
||||
and users don't get a different token per application server
|
||||
(this could be implemented, we just didn't have any use case yet).
|
||||
|
||||
JWTs are only valid for one day: when they expire, a new JWT can be obtained
|
||||
from the authentication server using the refresh token. If the user stays
|
||||
logged in, they would only have to type their password once a month.
|
||||
|
||||
#### Logout
|
||||
|
||||
In order to handle logout correctly, we rely on a blacklist of JWTs. When a
|
||||
user logs out, their refresh token is removed from the backend, and all of
|
||||
their currently valid JWTs are added to a blacklist. Incoming requests are
|
||||
checked against this blacklist (in-memory, faster than calling the database).
|
||||
Applications that want to use these JWTs should subscribe to be notified of
|
||||
blacklisted JWTs (TODO: implement the PubSub service and API).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "lldap_auth"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = ["opaque_server", "opaque_client"]
|
||||
@@ -20,30 +20,13 @@ serde = "*"
|
||||
sha2 = "0.9"
|
||||
thiserror = "*"
|
||||
|
||||
# TODO: update to 0.6 when out.
|
||||
[dependencies.opaque-ke]
|
||||
git = "https://github.com/novifinancial/opaque-ke"
|
||||
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
|
||||
version = "0.6"
|
||||
|
||||
[dependencies.chrono]
|
||||
version = "*"
|
||||
features = [ "serde" ]
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.sqlx]
|
||||
version = "0.5"
|
||||
features = [
|
||||
"any",
|
||||
"chrono",
|
||||
"macros",
|
||||
"mysql",
|
||||
"postgres",
|
||||
"runtime-actix-native-tls",
|
||||
"sqlite",
|
||||
]
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.sqlx-core]
|
||||
version = "=0.5.1"
|
||||
|
||||
# For WASM targets, use the JS getrandom.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::nonstandard_macro_braces)]
|
||||
use chrono::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
2
config.toml
Normal file
2
config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
38
docker-entrypoint.sh
Executable file
38
docker-entrypoint.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
for SECRET in LLDAP_JWT_SECRET LLDAP_LDAP_USER_PASS; do
|
||||
FILE_VAR="${SECRET}_FILE"
|
||||
SECRET_FILE="${!FILE_VAR:-}"
|
||||
if [[ -n "$SECRET_FILE" ]]; then
|
||||
if [[ -f "$SECRET_FILE" ]]; then
|
||||
declare "$SECRET=$(cat $SECRET_FILE)"
|
||||
export "$SECRET"
|
||||
echo "[entrypoint] Set $SECRET from $SECRET_FILE"
|
||||
else
|
||||
echo "[entrypoint] Could not read contents of $SECRET_FILE (specified in $FILE_VAR)" >&2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
CONFIG_FILE=/data/lldap_config.toml
|
||||
|
||||
if [[ ( ! -w "/data" ) ]] || [[ ( ! -d "/data" ) ]]; then
|
||||
echo "[entrypoint] The /data folder doesn't exist or cannot be written to. Make sure to mount
|
||||
a volume or folder to /data to persist data across restarts, and that the current user can
|
||||
write to it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
echo "[entrypoint] Copying the default config to $CONFIG_FILE"
|
||||
echo "[entrypoint] Edit this file to configure LLDAP."
|
||||
cp /app/lldap_config.docker_template.toml $CONFIG_FILE
|
||||
fi
|
||||
|
||||
if [[ ! -r "$CONFIG_FILE" ]]; then
|
||||
echo "[entrypoint] Config file is not readable. Check the permissions"
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
exec /app/lldap "$@"
|
||||
47
example_configs/authelia_config.yml
Normal file
47
example_configs/authelia_config.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
###############################################################
|
||||
# Authelia configuration #
|
||||
###############################################################
|
||||
|
||||
# This is just the LDAP part of the Authelia configuration!
|
||||
|
||||
|
||||
authentication_backend:
|
||||
# Password reset through authelia works normally.
|
||||
disable_reset_password: false
|
||||
# How often authelia should check if there is an user update in LDAP
|
||||
refresh_interval: 1m
|
||||
ldap:
|
||||
implementation: custom
|
||||
# Pattern is ldap://HOSTNAME-OR-IP:PORT
|
||||
# Normal ldap port is 389, standard in LLDAP is 3890
|
||||
url: ldap://lldap:3890
|
||||
# The dial timeout for LDAP.
|
||||
timeout: 5s
|
||||
# Use StartTLS with the LDAP connection, TLS not supported right now
|
||||
start_tls: false
|
||||
#tls:
|
||||
# skip_verify: false
|
||||
# minimum_version: TLS1.2
|
||||
# Set base dn, like dc=google,dc.com
|
||||
base_dn: dc=example,dc=com
|
||||
username_attribute: uid
|
||||
# You need to set this to ou=people, because all users are stored in this ou!
|
||||
additional_users_dn: ou=people
|
||||
# To allow sign in both with username and email, one can use a filter like
|
||||
# (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))
|
||||
users_filter: (&({username_attribute}={input})(objectClass=person))
|
||||
# Set this to ou=groups, because all groups are stored in this ou
|
||||
additional_groups_dn: ou=groups
|
||||
# Only this filter is supported right now
|
||||
groups_filter: (member={dn})
|
||||
# The attribute holding the name of the group.
|
||||
group_name_attribute: cn
|
||||
# Email attribute
|
||||
mail_attribute: mail
|
||||
# The attribute holding the display name of the user. This will be used to greet an authenticated user.
|
||||
display_name_attribute: displayName
|
||||
# The username and password of the admin user.
|
||||
# "admin" should be the admin username you set in the LLDAP configuration
|
||||
user: cn=admin,ou=people,dc=example,dc=com
|
||||
# Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
||||
password: 'REPLACE_ME'
|
||||
30
example_configs/jitsi_meet.conf
Normal file
30
example_configs/jitsi_meet.conf
Normal file
@@ -0,0 +1,30 @@
|
||||
# .env file
|
||||
|
||||
# Enable authentication
|
||||
ENABLE_AUTH=1
|
||||
|
||||
# Enable guest access
|
||||
ENABLE_GUESTS=1
|
||||
|
||||
# Select authentication type
|
||||
AUTH_TYPE=ldap
|
||||
|
||||
# LDAP authentication
|
||||
|
||||
# LDAP url for connection
|
||||
LDAP_URL=ldap://IP:3890
|
||||
|
||||
# LDAP base DN.
|
||||
LDAP_BASE=dc=example,dc=com
|
||||
|
||||
# LDAP user DN.
|
||||
LDAP_BINDDN=cn=admin,ou=people,dc=example,dc=com
|
||||
|
||||
# LLDAP admin password.
|
||||
LDAP_BINDPW=password
|
||||
|
||||
# LDAP filter.
|
||||
LDAP_FILTER=(&(uid=%u)(objectClass=person))
|
||||
|
||||
# LDAP authentication method
|
||||
LDAP_AUTH_METHOD=bind
|
||||
64
example_configs/keycloak.md
Normal file
64
example_configs/keycloak.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# KeyCloak configuration
|
||||
|
||||
Configuring [KeyCloak](https://www.keycloak.org) takes a bit of effort. Once
|
||||
the KeyCloak instance is up and you logged in as admin (see [this
|
||||
guide](https://www.keycloak.org/getting-started/getting-started-docker) to get
|
||||
started with KeyCloak), you'll need to configure the LDAP mapping.
|
||||
|
||||
Keep in mind that LLDAP is _read-only_: that means that if you create some
|
||||
users in KeyCloak, they won't be reflected to LLDAP. Instead, you should create
|
||||
the user from LLDAP, and it will appear in KeyCloak. Same for groups. However,
|
||||
you can set the permissions associated with users or groups in KeyCloak.
|
||||
|
||||
## Configure user authentication
|
||||
|
||||
In the admin console of KeyCloak, on the left, go to "User Federation". You can
|
||||
then add an LDAP backend.
|
||||
|
||||
The key settings are:
|
||||
|
||||
- Edit Mode: `READ_ONLY`
|
||||
- Vendor: `Other`
|
||||
- Username LDAP attribute: `uid`
|
||||
- UUID LDAP attribute: `uid`
|
||||
- User Object Classes: `person`
|
||||
- Connection URL: `ldap://<your-lldap-container>:3890`
|
||||
- Users DN: `ou=people,dc=example,dc=com` (or whatever `dc` you have)
|
||||
- Bind Type: `simple`
|
||||
- Bind DN: `cn=admin,ou=people,dc=example,dc=com` (replace with your admin user and `dc`)
|
||||
- Bind Credential: your LLDAP admin password
|
||||
|
||||
Test the connection and authentication, it should work.
|
||||
|
||||
In the "Advanced Settings", you can "Query Supported Extensions", or just
|
||||
enable the "LDAPv3 Password Modify Extended Operation".
|
||||
|
||||
Turn "Pagination" off.
|
||||
|
||||
Save the provider.
|
||||
|
||||
## Configure group mapping
|
||||
|
||||
Getting the LDAP groups to be imported into KeyCloak requires one more step:
|
||||
|
||||
Go back to "User Federation", and edit your LDAP integration. At the top, click
|
||||
on the "Mappers" tab.
|
||||
|
||||
Find or create the `groups` mapper, with type `group-ldap-mapper`. The key
|
||||
settings are:
|
||||
|
||||
- LDAP Groups DN: `ou=groups,dc=example,dc=com` (or whatever `dc` you have)
|
||||
- Group Name LDAP Attribute: `cn`
|
||||
- Group Object Classes: `groupOfUniqueNames`
|
||||
- Mode: `READ_ONLY`
|
||||
|
||||
Save, then sync LDAP groups to KeyCloak, and (from the LDAP integration page)
|
||||
sync the users to KeyCloak as well.
|
||||
|
||||
## Give the LDAP admin user admin rights to KeyCloak
|
||||
|
||||
Once the groups are synchronized, go to "Manage > Groups" on the left. Click on
|
||||
`lldap_admin`, then "Edit".
|
||||
|
||||
Assign the role "admin" to the group. Now you can log in as the LLDAP admin to
|
||||
the KeyCloak admin console.
|
||||
@@ -1,6 +1,7 @@
|
||||
## Default configuration for Docker.
|
||||
## All the values can be overridden through environment variables. For
|
||||
## instance, "ldap_port" can be overridden with the "LDAP_PORT" variable.
|
||||
## All the values can be overridden through environment variables, prefixed
|
||||
## with "LLDAP_". For instance, "ldap_port" can be overridden with the
|
||||
## "LLDAP_LDAP_PORT" variable.
|
||||
|
||||
## The port on which to have the LDAP server.
|
||||
#ldap_port = 3890
|
||||
@@ -9,13 +10,18 @@
|
||||
## administration.
|
||||
#http_port = 17170
|
||||
|
||||
## The public URL of the server, for password reset links.
|
||||
#http_url = "http://localhost"
|
||||
|
||||
## Random secret for JWT signature.
|
||||
## This secret should be random, and should be shared with application
|
||||
## servers that need to consume the JWTs.
|
||||
## Changing this secret will invalidate all user sessions and require
|
||||
## them to re-login.
|
||||
## You should probably set it through the JWT_SECRET environment
|
||||
## You should probably set it through the LLDAP_JWT_SECRET environment
|
||||
## variable from a secret ".env" file.
|
||||
## This can also be set from a file's contents by specifying the file path
|
||||
## in the LLDAP_JWT_SECRET_FILE environment variable
|
||||
## You can generate it with (on linux):
|
||||
## LC_ALL=C tr -dc 'A-Za-z0-9!"#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
|
||||
#jwt_secret = "REPLACE_WITH_RANDOM"
|
||||
@@ -31,16 +37,19 @@
|
||||
|
||||
## Admin username.
|
||||
## For the LDAP interface, a value of "admin" here will create the LDAP
|
||||
## user "cn=admin,dc=example,dc=com" (with the base DN above).
|
||||
## user "cn=admin,ou=people,dc=example,dc=com" (with the base DN above).
|
||||
## For the administration interface, this is the username.
|
||||
#ldap_user_dn = "admin"
|
||||
|
||||
## Admin password.
|
||||
## Password for the admin account, both for the LDAP bind and for the
|
||||
## administration interface.
|
||||
## administration interface. It is only used when initially creating
|
||||
## the admin user.
|
||||
## It should be minimum 8 characters long.
|
||||
## You can set it with the LDAP_USER_PASS environment variable.
|
||||
## Note: you can create another admin user for LDAP/administration, this
|
||||
## You can set it with the LLDAP_LDAP_USER_PASS environment variable.
|
||||
## This can also be set from a file's contents by specifying the file path
|
||||
## in the LLDAP_USER_PASS_FILE environment variable
|
||||
## Note: you can create another admin user for user administration, this
|
||||
## is just the default one.
|
||||
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
|
||||
|
||||
@@ -64,3 +73,25 @@ database_url = "sqlite:///data/users.db?mode=rwc"
|
||||
## each password.
|
||||
## Randomly generated on first run if it doesn't exist.
|
||||
key_file = "/data/private_key"
|
||||
|
||||
## Options to configure SMTP parameters, to send password reset emails.
|
||||
## To set these options from environment variables, use the following format
|
||||
## (example with "password"): LLDAP_SMTP_OPTIONS__PASSWORD
|
||||
#[smtp_options]
|
||||
## Whether to enabled password reset via email, from LLDAP.
|
||||
#enable_password_reset=true
|
||||
## The SMTP server.
|
||||
#server="smtp.gmail.com"
|
||||
## The SMTP port.
|
||||
#port=587
|
||||
## Whether to connect with TLS.
|
||||
#tls_required=true
|
||||
## The SMTP user, usually your email address.
|
||||
#user="sender@gmail.com"
|
||||
## The SMTP password.
|
||||
#password="password"
|
||||
## The header field, optional: how the sender appears in the email. The first
|
||||
## is a free-form name, followed by an email between <>.
|
||||
#from="LLDAP Admin <sender@gmail.com>"
|
||||
## Same for reply-to, optional.
|
||||
#reply_to="Do not reply <noreply@localhost>"
|
||||
|
||||
25
prepare-release.sh
Executable file
25
prepare-release.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#! /bin/sh
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
# Build the binary server, for x86_64.
|
||||
cargo build --release -p lldap
|
||||
|
||||
cargo install cross
|
||||
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
|
||||
|
||||
# Build the frontend.
|
||||
./app/build.sh
|
||||
|
||||
VERSION=$(git describe --tags)
|
||||
|
||||
mkdir -p /tmp/release/x86_64
|
||||
cp target/release/lldap /tmp/release/x86_64
|
||||
cp -R app/index.html app/main.js app/pkg lldap_config.docker_template.toml README.md LICENSE /tmp/release/x86_64
|
||||
tar -czvf lldap-x86_64-${VERSION}.tar.gz /tmp/release/x86_64
|
||||
|
||||
mkdir -p /tmp/release/armv7
|
||||
cp target/armv7-unknown-linux-musleabihf/release/lldap /tmp/release/armv7
|
||||
cp -R app/index.html app/main.js app/pkg lldap_config.docker_template.toml README.md LICENSE /tmp/release/armv7
|
||||
tar -czvf lldap-armv7-${VERSION}.tar.gz /tmp/release/armv7
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
name = "lldap"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
|
||||
[dependencies]
|
||||
actix = "0.12"
|
||||
@@ -26,7 +26,7 @@ futures-util = "*"
|
||||
hmac = "0.10"
|
||||
http = "*"
|
||||
jwt = "0.13"
|
||||
ldap3_server = "*"
|
||||
ldap3_server = ">=0.1.9"
|
||||
lldap_auth = { path = "../auth" }
|
||||
log = "*"
|
||||
orion = "0.16"
|
||||
@@ -38,19 +38,28 @@ thiserror = "*"
|
||||
time = "0.2"
|
||||
tokio = { version = "1.2.0", features = ["full"] }
|
||||
tokio-util = "0.6.3"
|
||||
tokio-stream = "*"
|
||||
tracing = "*"
|
||||
tracing-actix-web = "0.4.0-beta.7"
|
||||
tracing-log = "*"
|
||||
tracing-subscriber = "*"
|
||||
tracing-subscriber = "0.3"
|
||||
rand = { version = "0.8", features = ["small_rng", "getrandom"] }
|
||||
juniper_actix = "0.4.0"
|
||||
juniper = "0.15.6"
|
||||
itertools = "0.10.1"
|
||||
|
||||
# TODO: update to 0.6 when out.
|
||||
[dependencies.opaque-ke]
|
||||
git = "https://github.com/novifinancial/opaque-ke"
|
||||
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
|
||||
version = "0.6"
|
||||
|
||||
[dependencies.lettre]
|
||||
version = "0.10.0-rc.3"
|
||||
features = [
|
||||
"builder",
|
||||
"serde",
|
||||
"smtp-transport",
|
||||
"tokio1-native-tls",
|
||||
"tokio1",
|
||||
]
|
||||
|
||||
[dependencies.sqlx]
|
||||
version = "0.5.1"
|
||||
@@ -72,5 +81,13 @@ features = ["with-chrono"]
|
||||
features = ["env", "toml"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.secstr]
|
||||
features = ["serde"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.openssl-sys]
|
||||
features = ["vendored"]
|
||||
version = "*"
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = "0.9.1"
|
||||
|
||||
@@ -28,9 +28,18 @@ mockall::mock! {
|
||||
}
|
||||
#[async_trait]
|
||||
impl OpaqueHandler for TestOpaqueHandler {
|
||||
async fn login_start(&self, request: login::ClientLoginStartRequest) -> Result<login::ServerLoginStartResponse>;
|
||||
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<String>;
|
||||
async fn registration_start(&self, request: registration::ClientRegistrationStartRequest) -> Result<registration::ServerRegistrationStartResponse>;
|
||||
async fn registration_finish(&self, request: registration::ClientRegistrationFinishRequest ) -> Result<()>;
|
||||
async fn login_start(
|
||||
&self,
|
||||
request: login::ClientLoginStartRequest
|
||||
) -> Result<login::ServerLoginStartResponse>;
|
||||
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<String>;
|
||||
async fn registration_start(
|
||||
&self,
|
||||
request: registration::ClientRegistrationStartRequest
|
||||
) -> Result<registration::ServerRegistrationStartResponse>;
|
||||
async fn registration_finish(
|
||||
&self,
|
||||
request: registration::ClientRegistrationFinishRequest
|
||||
) -> Result<()>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@ mod tests {
|
||||
let sql_pool = get_in_memory_db().await;
|
||||
let config = ConfigurationBuilder::default()
|
||||
.ldap_user_dn("admin".to_string())
|
||||
.ldap_user_pass("test".to_string())
|
||||
.ldap_user_pass(secstr::SecUtf8::from("test"))
|
||||
.build()
|
||||
.unwrap();
|
||||
let handler = SqlBackendHandler::new(config, sql_pool);
|
||||
|
||||
@@ -9,6 +9,7 @@ use async_trait::async_trait;
|
||||
use lldap_auth::opaque;
|
||||
use log::*;
|
||||
use sea_query::{Expr, Iden, Query};
|
||||
use secstr::SecUtf8;
|
||||
use sqlx::Row;
|
||||
|
||||
type SqlOpaqueHandler = SqlBackendHandler;
|
||||
@@ -83,7 +84,7 @@ impl SqlBackendHandler {
|
||||
impl LoginHandler for SqlBackendHandler {
|
||||
async fn bind(&self, request: BindRequest) -> Result<()> {
|
||||
if request.name == self.config.ldap_user_dn {
|
||||
if request.password == self.config.ldap_user_pass {
|
||||
if SecUtf8::from(request.password) == self.config.ldap_user_pass {
|
||||
return Ok(());
|
||||
} else {
|
||||
debug!(r#"Invalid password for LDAP bind user"#);
|
||||
@@ -220,11 +221,12 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
pub(crate) async fn register_password(
|
||||
opaque_handler: &SqlOpaqueHandler,
|
||||
username: &str,
|
||||
password: &str,
|
||||
password: &SecUtf8,
|
||||
) -> Result<()> {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
use registration::*;
|
||||
let registration_start = opaque::client::registration::start_registration(password, &mut rng)?;
|
||||
let registration_start =
|
||||
opaque::client::registration::start_registration(password.unsecure(), &mut rng)?;
|
||||
let start_response = opaque_handler
|
||||
.registration_start(ClientRegistrationStartRequest {
|
||||
username: username.to_string(),
|
||||
@@ -321,7 +323,7 @@ mod tests {
|
||||
attempt_login(&opaque_handler, "bob", "bob00")
|
||||
.await
|
||||
.unwrap_err();
|
||||
register_password(&opaque_handler, "bob", "bob00").await?;
|
||||
register_password(&opaque_handler, "bob", &secstr::SecUtf8::from("bob00")).await?;
|
||||
attempt_login(&opaque_handler, "bob", "wrong_password")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
@@ -15,6 +15,7 @@ use actix_web::{
|
||||
error::{ErrorBadRequest, ErrorUnauthorized},
|
||||
web, HttpRequest, HttpResponse,
|
||||
};
|
||||
use actix_web_httpauth::extractors::bearer::BearerAuth;
|
||||
use anyhow::Result;
|
||||
use chrono::prelude::*;
|
||||
use futures::future::{ok, Ready};
|
||||
@@ -22,6 +23,7 @@ use futures_util::{FutureExt, TryFutureExt};
|
||||
use hmac::Hmac;
|
||||
use jwt::{SignWithKey, VerifyWithKey};
|
||||
use lldap_auth::{login, registration, JWTClaims};
|
||||
use log::*;
|
||||
use sha2::Sha512;
|
||||
use std::collections::{hash_map::DefaultHasher, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -101,7 +103,7 @@ where
|
||||
.cookie(
|
||||
Cookie::build("token", token.as_str())
|
||||
.max_age(1.days())
|
||||
.path("/api")
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
@@ -111,6 +113,79 @@ where
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
async fn get_password_reset_step1<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let user_id = match request.match_info().get("user_id") {
|
||||
None => return HttpResponse::BadRequest().body("Missing user ID"),
|
||||
Some(id) => id,
|
||||
};
|
||||
let token = match data.backend_handler.start_password_reset(user_id).await {
|
||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
||||
Ok(None) => return HttpResponse::Ok().finish(),
|
||||
Ok(Some(token)) => token,
|
||||
};
|
||||
let user = match data.backend_handler.get_user_details(user_id).await {
|
||||
Err(e) => {
|
||||
warn!("Error getting used details: {:#?}", e);
|
||||
return HttpResponse::Ok().finish();
|
||||
}
|
||||
Ok(u) => u,
|
||||
};
|
||||
if let Err(e) = super::mail::send_password_reset_email(
|
||||
&user.display_name,
|
||||
&user.email,
|
||||
&token,
|
||||
&data.server_url,
|
||||
&data.mail_options,
|
||||
) {
|
||||
warn!("Error sending email: {:#?}", e);
|
||||
}
|
||||
HttpResponse::Ok().finish()
|
||||
}
|
||||
|
||||
async fn get_password_reset_step2<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let token = match request.match_info().get("token") {
|
||||
None => return HttpResponse::BadRequest().body("Missing token"),
|
||||
Some(token) => token,
|
||||
};
|
||||
let user_id = match data
|
||||
.backend_handler
|
||||
.get_user_id_for_password_reset_token(token)
|
||||
.await
|
||||
{
|
||||
Err(_) => return HttpResponse::Unauthorized().body("Invalid or expired token"),
|
||||
Ok(user_id) => user_id,
|
||||
};
|
||||
let _ = data
|
||||
.backend_handler
|
||||
.delete_password_reset_token(token)
|
||||
.await;
|
||||
let groups = HashSet::new();
|
||||
let token = create_jwt(&data.jwt_key, user_id.to_string(), groups);
|
||||
HttpResponse::Ok()
|
||||
.cookie(
|
||||
Cookie::build("token", token.as_str())
|
||||
.max_age(5.minutes())
|
||||
// Cookie is only valid to reset the password.
|
||||
.path("/auth")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
)
|
||||
.json(user_id)
|
||||
}
|
||||
|
||||
async fn get_logout<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
@@ -148,7 +223,7 @@ where
|
||||
.cookie(
|
||||
Cookie::build("token", "")
|
||||
.max_age(0.days())
|
||||
.path("/api")
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
@@ -203,7 +278,7 @@ where
|
||||
.cookie(
|
||||
Cookie::build("token", token.as_str())
|
||||
.max_age(1.days())
|
||||
.path("/api")
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
@@ -254,14 +329,45 @@ where
|
||||
}
|
||||
|
||||
async fn opaque_register_start<Backend>(
|
||||
request: actix_web::HttpRequest,
|
||||
mut payload: actix_web::web::Payload,
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<registration::ClientRegistrationStartRequest>,
|
||||
) -> ApiResult<registration::ServerRegistrationStartResponse>
|
||||
where
|
||||
Backend: OpaqueHandler + 'static,
|
||||
{
|
||||
use actix_web::FromRequest;
|
||||
let validation_result = match BearerAuth::from_request(&request, &mut payload.0)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
|
||||
{
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return ApiResult::Right(
|
||||
HttpResponse::Unauthorized().body("Not authorized to change the user's password"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let registration_start_request =
|
||||
match web::Json::<registration::ClientRegistrationStartRequest>::from_request(
|
||||
&request,
|
||||
&mut payload.0,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return ApiResult::Right(
|
||||
HttpResponse::BadRequest().body(format!("Bad request: {:#?}", e)),
|
||||
)
|
||||
}
|
||||
}
|
||||
.into_inner();
|
||||
let user_id = ®istration_start_request.username;
|
||||
validation_result.can_access(user_id);
|
||||
data.backend_handler
|
||||
.registration_start(request.into_inner())
|
||||
.registration_start(registration_start_request)
|
||||
.await
|
||||
.map(|res| ApiResult::Left(web::Json(res)))
|
||||
.unwrap_or_else(error_to_api_response)
|
||||
@@ -402,14 +508,25 @@ where
|
||||
web::resource("/opaque/login/finish")
|
||||
.route(web::post().to(opaque_login_finish::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/opaque/register/start")
|
||||
.route(web::post().to(opaque_register_start::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/opaque/register/finish")
|
||||
.route(web::post().to(opaque_register_finish::<Backend>)),
|
||||
)
|
||||
.service(web::resource("/refresh").route(web::get().to(get_refresh::<Backend>)))
|
||||
.service(web::resource("/logout").route(web::get().to(get_logout::<Backend>)));
|
||||
.service(
|
||||
web::resource("/reset/step1/{user_id}")
|
||||
.route(web::get().to(get_password_reset_step1::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/reset/step2/{token}")
|
||||
.route(web::get().to(get_password_reset_step2::<Backend>)),
|
||||
)
|
||||
.service(web::resource("/logout").route(web::get().to(get_logout::<Backend>)))
|
||||
.service(
|
||||
web::scope("/opaque/register")
|
||||
.wrap(CookieToHeaderTranslatorFactory)
|
||||
.service(
|
||||
web::resource("/start").route(web::post().to(opaque_register_start::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/finish")
|
||||
.route(web::post().to(opaque_register_finish::<Backend>)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use clap::Clap;
|
||||
use lettre::message::Mailbox;
|
||||
|
||||
/// lldap is a lightweight LDAP server
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
@@ -9,6 +10,7 @@ pub struct CLIOpts {
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
pub enum Command {
|
||||
/// Export the GraphQL schema to *.graphql.
|
||||
@@ -17,25 +19,100 @@ pub enum Command {
|
||||
/// Run the LDAP and GraphQL server.
|
||||
#[clap(name = "run")]
|
||||
Run(RunOpts),
|
||||
/// Send a test email.
|
||||
#[clap(name = "send_test_email")]
|
||||
SendTestEmail(TestEmailOpts),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
pub struct GeneralConfigOpts {
|
||||
/// Change config file name.
|
||||
#[clap(
|
||||
short,
|
||||
long,
|
||||
default_value = "lldap_config.toml",
|
||||
env = "LLDAP_CONFIG_FILE"
|
||||
)]
|
||||
pub config_file: String,
|
||||
|
||||
/// Set verbose logging.
|
||||
#[clap(short, long)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
pub struct RunOpts {
|
||||
/// Change config file name
|
||||
#[clap(short, long, default_value = "lldap_config.toml")]
|
||||
pub config_file: String,
|
||||
#[clap(flatten)]
|
||||
pub general_config: GeneralConfigOpts,
|
||||
|
||||
/// Change ldap port. Default: 389
|
||||
#[clap(long)]
|
||||
/// Path to the file that contains the private server key.
|
||||
/// It will be created if it doesn't exist.
|
||||
#[clap(long, env = "LLDAP_SERVER_KEY_FILE")]
|
||||
pub server_key_file: Option<String>,
|
||||
|
||||
/// Change ldap port. Default: 3890
|
||||
#[clap(long, env = "LLDAP_LDAP_PORT")]
|
||||
pub ldap_port: Option<u16>,
|
||||
|
||||
/// Change ldap ssl port. Default: 636
|
||||
#[clap(long)]
|
||||
/// Change ldap ssl port. Default: 6360
|
||||
#[clap(long, env = "LLDAP_LDAPS_PORT")]
|
||||
pub ldaps_port: Option<u16>,
|
||||
|
||||
/// Set verbose logging
|
||||
#[clap(short, long)]
|
||||
pub verbose: bool,
|
||||
/// Change HTTP API port. Default: 17170
|
||||
#[clap(long, env = "LLDAP_HTTP_PORT")]
|
||||
pub http_port: Option<u16>,
|
||||
|
||||
/// URL of the server, for password reset links.
|
||||
#[clap(long, env = "LLDAP_HTTP_URL")]
|
||||
pub http_url: Option<String>,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub smtp_opts: SmtpOpts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
pub struct TestEmailOpts {
|
||||
#[clap(flatten)]
|
||||
pub general_config: GeneralConfigOpts,
|
||||
|
||||
/// Email address to send an email to.
|
||||
#[clap(long, env = "LLDAP_TEST_EMAIL_TO")]
|
||||
pub to: String,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub smtp_opts: SmtpOpts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
pub struct SmtpOpts {
|
||||
/// Sender email address.
|
||||
#[clap(long)]
|
||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")]
|
||||
pub smtp_from: Option<Mailbox>,
|
||||
|
||||
/// Reply-to email address.
|
||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__TO")]
|
||||
pub smtp_reply_to: Option<Mailbox>,
|
||||
|
||||
/// SMTP server.
|
||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__SERVER")]
|
||||
pub smtp_server: Option<String>,
|
||||
|
||||
/// SMTP port, 587 by default.
|
||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__PORT")]
|
||||
pub smtp_port: Option<u16>,
|
||||
|
||||
/// SMTP user.
|
||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__USER")]
|
||||
pub smtp_user: Option<String>,
|
||||
|
||||
/// SMTP password.
|
||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__PASSWORD", hide_env_values = true)]
|
||||
pub smtp_password: Option<String>,
|
||||
|
||||
/// Whether TLS should be used to connect to SMTP.
|
||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__TLS_REQUIRED")]
|
||||
pub smtp_tls_required: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
|
||||
@@ -1,49 +1,83 @@
|
||||
use crate::infra::cli::{GeneralConfigOpts, RunOpts, SmtpOpts, TestEmailOpts};
|
||||
use anyhow::{Context, Result};
|
||||
use figment::{
|
||||
providers::{Env, Format, Serialized, Toml},
|
||||
Figment,
|
||||
};
|
||||
use lettre::message::Mailbox;
|
||||
use lldap_auth::opaque::{server::ServerSetup, KeyPair};
|
||||
use log::*;
|
||||
use secstr::SecUtf8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::infra::cli::RunOpts;
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
|
||||
#[builder(pattern = "owned")]
|
||||
pub struct MailOptions {
|
||||
#[builder(default = "false")]
|
||||
pub enable_password_reset: bool,
|
||||
#[builder(default = "None")]
|
||||
pub from: Option<Mailbox>,
|
||||
#[builder(default = "None")]
|
||||
pub reply_to: Option<Mailbox>,
|
||||
#[builder(default = r#""localhost".to_string()"#)]
|
||||
pub server: String,
|
||||
#[builder(default = "587")]
|
||||
pub port: u16,
|
||||
#[builder(default = r#""admin".to_string()"#)]
|
||||
pub user: String,
|
||||
#[builder(default = r#"SecUtf8::from("")"#)]
|
||||
pub password: SecUtf8,
|
||||
#[builder(default = "true")]
|
||||
pub tls_required: bool,
|
||||
}
|
||||
|
||||
impl std::default::Default for MailOptions {
|
||||
fn default() -> Self {
|
||||
MailOptionsBuilder::default().build().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
|
||||
#[builder(
|
||||
pattern = "owned",
|
||||
default = "Configuration::default()",
|
||||
build_fn(name = "private_build", validate = "Self::validate")
|
||||
)]
|
||||
#[builder(pattern = "owned", build_fn(name = "private_build"))]
|
||||
pub struct Configuration {
|
||||
#[builder(default = "3890")]
|
||||
pub ldap_port: u16,
|
||||
#[builder(default = "6360")]
|
||||
pub ldaps_port: u16,
|
||||
#[builder(default = "17170")]
|
||||
pub http_port: u16,
|
||||
pub jwt_secret: String,
|
||||
#[builder(default = r#"SecUtf8::from("secretjwtsecret")"#)]
|
||||
pub jwt_secret: SecUtf8,
|
||||
#[builder(default = r#"String::from("dc=example,dc=com")"#)]
|
||||
pub ldap_base_dn: String,
|
||||
#[builder(default = r#"String::from("admin")"#)]
|
||||
pub ldap_user_dn: String,
|
||||
pub ldap_user_pass: String,
|
||||
#[builder(default = r#"SecUtf8::from("password")"#)]
|
||||
pub ldap_user_pass: SecUtf8,
|
||||
#[builder(default = r#"String::from("sqlite://users.db?mode=rwc")"#)]
|
||||
pub database_url: String,
|
||||
#[builder(default = "false")]
|
||||
pub verbose: bool,
|
||||
#[builder(default = r#"String::from("server_key")"#)]
|
||||
pub key_file: String,
|
||||
#[builder(default)]
|
||||
pub smtp_options: MailOptions,
|
||||
#[builder(default = r#"String::from("http://localhost")"#)]
|
||||
pub http_url: String,
|
||||
#[serde(skip)]
|
||||
#[builder(field(private), setter(strip_option))]
|
||||
#[builder(field(private), default = "None")]
|
||||
server_setup: Option<ServerSetup>,
|
||||
}
|
||||
|
||||
impl std::default::Default for Configuration {
|
||||
fn default() -> Self {
|
||||
ConfigurationBuilder::default().build().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigurationBuilder {
|
||||
#[cfg(test)]
|
||||
pub fn build(self) -> Result<Configuration> {
|
||||
let server_setup = get_server_setup(self.key_file.as_deref().unwrap_or("server_key"))?;
|
||||
Ok(self.server_setup(server_setup).private_build()?)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.server_setup.is_none() {
|
||||
Err("Don't use `private_build`, use `build` instead".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
Ok(self.server_setup(Some(server_setup)).private_build()?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,39 +89,6 @@ impl Configuration {
|
||||
pub fn get_server_keys(&self) -> &KeyPair {
|
||||
self.get_server_setup().keypair()
|
||||
}
|
||||
|
||||
fn merge_with_cli(mut self: Configuration, cli_opts: RunOpts) -> Configuration {
|
||||
if cli_opts.verbose {
|
||||
self.verbose = true;
|
||||
}
|
||||
|
||||
if let Some(port) = cli_opts.ldap_port {
|
||||
self.ldap_port = port;
|
||||
}
|
||||
|
||||
if let Some(port) = cli_opts.ldaps_port {
|
||||
self.ldaps_port = port;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn default() -> Self {
|
||||
Configuration {
|
||||
ldap_port: 3890,
|
||||
ldaps_port: 6360,
|
||||
http_port: 17170,
|
||||
jwt_secret: String::from("secretjwtsecret"),
|
||||
ldap_base_dn: String::from("dc=example,dc=com"),
|
||||
// cn=admin,dc=example,dc=com
|
||||
ldap_user_dn: String::from("admin"),
|
||||
ldap_user_pass: String::from("password"),
|
||||
database_url: String::from("sqlite://users.db?mode=rwc"),
|
||||
verbose: false,
|
||||
key_file: String::from("server_key"),
|
||||
server_setup: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_server_setup(file_path: &str) -> Result<ServerSetup> {
|
||||
@@ -108,17 +109,122 @@ fn get_server_setup(file_path: &str) -> Result<ServerSetup> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(cli_opts: RunOpts) -> Result<Configuration> {
|
||||
let config_file = cli_opts.config_file.clone();
|
||||
pub trait ConfigOverrider {
|
||||
fn override_config(&self, config: &mut Configuration);
|
||||
}
|
||||
|
||||
info!("Loading configuration from {}", cli_opts.config_file);
|
||||
pub trait TopLevelCommandOpts {
|
||||
fn general_config(&self) -> &GeneralConfigOpts;
|
||||
}
|
||||
|
||||
let config: Configuration = Figment::from(Serialized::defaults(Configuration::default()))
|
||||
.merge(Toml::file(config_file))
|
||||
.merge(Env::prefixed("LLDAP_"))
|
||||
.extract()?;
|
||||
impl TopLevelCommandOpts for RunOpts {
|
||||
fn general_config(&self) -> &GeneralConfigOpts {
|
||||
&self.general_config
|
||||
}
|
||||
}
|
||||
|
||||
let mut config = config.merge_with_cli(cli_opts);
|
||||
impl TopLevelCommandOpts for TestEmailOpts {
|
||||
fn general_config(&self) -> &GeneralConfigOpts {
|
||||
&self.general_config
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigOverrider for RunOpts {
|
||||
fn override_config(&self, config: &mut Configuration) {
|
||||
self.general_config.override_config(config);
|
||||
|
||||
if let Some(path) = self.server_key_file.as_ref() {
|
||||
config.key_file = path.to_string();
|
||||
}
|
||||
|
||||
if let Some(port) = self.ldap_port {
|
||||
config.ldap_port = port;
|
||||
}
|
||||
|
||||
if let Some(port) = self.ldaps_port {
|
||||
config.ldaps_port = port;
|
||||
}
|
||||
|
||||
if let Some(port) = self.http_port {
|
||||
config.http_port = port;
|
||||
}
|
||||
|
||||
if let Some(url) = self.http_url.as_ref() {
|
||||
config.http_url = url.to_string();
|
||||
}
|
||||
self.smtp_opts.override_config(config);
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigOverrider for TestEmailOpts {
|
||||
fn override_config(&self, config: &mut Configuration) {
|
||||
self.general_config.override_config(config);
|
||||
self.smtp_opts.override_config(config);
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigOverrider for GeneralConfigOpts {
|
||||
fn override_config(&self, config: &mut Configuration) {
|
||||
if self.verbose {
|
||||
config.verbose = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigOverrider for SmtpOpts {
|
||||
fn override_config(&self, config: &mut Configuration) {
|
||||
if let Some(from) = &self.smtp_from {
|
||||
config.smtp_options.from = Some(from.clone());
|
||||
}
|
||||
if let Some(reply_to) = &self.smtp_reply_to {
|
||||
config.smtp_options.reply_to = Some(reply_to.clone());
|
||||
}
|
||||
if let Some(server) = &self.smtp_server {
|
||||
config.smtp_options.server = server.clone();
|
||||
}
|
||||
if let Some(port) = self.smtp_port {
|
||||
config.smtp_options.port = port;
|
||||
}
|
||||
if let Some(user) = &self.smtp_user {
|
||||
config.smtp_options.user = user.clone();
|
||||
}
|
||||
if let Some(password) = &self.smtp_password {
|
||||
config.smtp_options.password = SecUtf8::from(password.clone());
|
||||
}
|
||||
if let Some(tls_required) = self.smtp_tls_required {
|
||||
config.smtp_options.tls_required = tls_required;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init<C>(overrides: C) -> Result<Configuration>
|
||||
where
|
||||
C: TopLevelCommandOpts + ConfigOverrider,
|
||||
{
|
||||
let config_file = overrides.general_config().config_file.clone();
|
||||
|
||||
println!(
|
||||
"Loading configuration from {}",
|
||||
overrides.general_config().config_file
|
||||
);
|
||||
|
||||
let mut config: Configuration = Figment::from(Serialized::defaults(
|
||||
ConfigurationBuilder::default().private_build().unwrap(),
|
||||
))
|
||||
.merge(Toml::file(config_file))
|
||||
.merge(Env::prefixed("LLDAP_").split("__"))
|
||||
.extract()?;
|
||||
|
||||
overrides.override_config(&mut config);
|
||||
if config.verbose {
|
||||
println!("Configuration: {:#?}", &config);
|
||||
}
|
||||
config.server_setup = Some(get_server_setup(&config.key_file)?);
|
||||
if config.jwt_secret == SecUtf8::from("secretjwtsecret") {
|
||||
println!("WARNING: Default JWT secret used! This is highly unsafe and can allow attackers to log in as admin.");
|
||||
}
|
||||
if config.ldap_user_pass == SecUtf8::from("password") {
|
||||
println!("WARNING: Unsecure default admin password is used.");
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::domain::handler::{BackendHandler, GroupId, GroupIdAndName};
|
||||
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::convert::TryInto;
|
||||
|
||||
type DomainRequestFilter = crate::domain::handler::RequestFilter;
|
||||
type DomainUser = crate::domain::handler::User;
|
||||
|
||||
@@ -21,6 +21,15 @@ pub enum JwtStorage {
|
||||
Blacklisted,
|
||||
}
|
||||
|
||||
/// Contains the temporary tokens to reset the password, sent by email.
|
||||
#[derive(Iden)]
|
||||
pub enum PasswordResetTokens {
|
||||
Table,
|
||||
Token,
|
||||
UserId,
|
||||
ExpiryDate,
|
||||
}
|
||||
|
||||
/// This needs to be initialized after the domain tables are.
|
||||
pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
sqlx::query(
|
||||
@@ -95,5 +104,38 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
&Table::create()
|
||||
.table(PasswordResetTokens::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(PasswordResetTokens::Token)
|
||||
.string_len(255)
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(PasswordResetTokens::UserId)
|
||||
.string_len(255)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(PasswordResetTokens::ExpiryDate)
|
||||
.date_time()
|
||||
.not_null(),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("PasswordResetTokensUserForeignKey")
|
||||
.table(PasswordResetTokens::Table, Users::Table)
|
||||
.col(PasswordResetTokens::UserId, Users::UserId)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,16 @@
|
||||
use crate::domain::handler::{BackendHandler, LoginHandler};
|
||||
use crate::infra::configuration::Configuration;
|
||||
use crate::infra::ldap_handler::LdapHandler;
|
||||
use crate::{
|
||||
domain::{
|
||||
handler::{BackendHandler, LoginHandler},
|
||||
opaque_handler::OpaqueHandler,
|
||||
},
|
||||
infra::{configuration::Configuration, ldap_handler::LdapHandler},
|
||||
};
|
||||
use actix_rt::net::TcpStream;
|
||||
use actix_server::ServerBuilder;
|
||||
use actix_service::{fn_service, ServiceFactoryExt};
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::future::ok;
|
||||
use ldap3_server::simple::*;
|
||||
use ldap3_server::LdapCodec;
|
||||
use ldap3_server::{proto::LdapMsg, LdapCodec};
|
||||
use log::*;
|
||||
use tokio::net::tcp::WriteHalf;
|
||||
use tokio_util::codec::{FramedRead, FramedWrite};
|
||||
@@ -18,36 +21,31 @@ async fn handle_incoming_message<Backend>(
|
||||
session: &mut LdapHandler<Backend>,
|
||||
) -> Result<bool>
|
||||
where
|
||||
Backend: BackendHandler + LoginHandler,
|
||||
Backend: BackendHandler + LoginHandler + OpaqueHandler,
|
||||
{
|
||||
use futures_util::SinkExt;
|
||||
use std::convert::TryFrom;
|
||||
let server_op = match msg.map_err(|_e| ()).and_then(ServerOps::try_from) {
|
||||
Ok(a_value) => a_value,
|
||||
Err(an_error) => {
|
||||
let _err = resp
|
||||
.send(DisconnectionNotice::gen(
|
||||
LdapResultCode::Other,
|
||||
"Internal Server Error",
|
||||
))
|
||||
.await;
|
||||
let _err = resp.flush().await;
|
||||
bail!("Internal server error: {:?}", an_error);
|
||||
}
|
||||
};
|
||||
|
||||
match session.handle_ldap_message(server_op).await {
|
||||
let msg = msg.context("while receiving LDAP op")?;
|
||||
debug!("Received LDAP message: {:?}", &msg);
|
||||
match session.handle_ldap_message(msg.op).await {
|
||||
None => return Ok(false),
|
||||
Some(result) => {
|
||||
for rmsg in result.into_iter() {
|
||||
if let Err(e) = resp.send(rmsg).await {
|
||||
bail!("Error while sending a response: {:?}", e);
|
||||
}
|
||||
if result.is_empty() {
|
||||
debug!("No response");
|
||||
}
|
||||
for result_op in result.into_iter() {
|
||||
debug!("Replying with LDAP op: {:?}", &result_op);
|
||||
resp.send(LdapMsg {
|
||||
msgid: msg.msgid,
|
||||
op: result_op,
|
||||
ctrl: vec![],
|
||||
})
|
||||
.await
|
||||
.context("while sending a response: {:#}")?
|
||||
}
|
||||
|
||||
if let Err(e) = resp.flush().await {
|
||||
bail!("Error while flushing responses: {:?}", e);
|
||||
}
|
||||
resp.flush()
|
||||
.await
|
||||
.context("while flushing responses: {:#}")?
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -59,14 +57,14 @@ pub fn build_ldap_server<Backend>(
|
||||
server_builder: ServerBuilder,
|
||||
) -> Result<ServerBuilder>
|
||||
where
|
||||
Backend: BackendHandler + LoginHandler + 'static,
|
||||
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let ldap_base_dn = config.ldap_base_dn.clone();
|
||||
let ldap_user_dn = config.ldap_user_dn.clone();
|
||||
Ok(
|
||||
server_builder.bind("ldap", ("0.0.0.0", config.ldap_port), move || {
|
||||
server_builder
|
||||
.bind("ldap", ("0.0.0.0", config.ldap_port), move || {
|
||||
let backend_handler = backend_handler.clone();
|
||||
let ldap_base_dn = ldap_base_dn.clone();
|
||||
let ldap_user_dn = ldap_user_dn.clone();
|
||||
@@ -83,7 +81,10 @@ where
|
||||
let mut session = LdapHandler::new(backend_handler, ldap_base_dn, ldap_user_dn);
|
||||
|
||||
while let Some(msg) = requests.next().await {
|
||||
if !handle_incoming_message(msg, &mut resp, &mut session).await? {
|
||||
if !handle_incoming_message(msg, &mut resp, &mut session)
|
||||
.await
|
||||
.context("while handling incoming messages")?
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -91,12 +92,11 @@ where
|
||||
Ok(stream)
|
||||
}
|
||||
})
|
||||
.map_err(|err: anyhow::Error| error!("Service Error: {:?}", err))
|
||||
// catch
|
||||
.map_err(|err: anyhow::Error| error!("Service Error: {:#}", err))
|
||||
.and_then(move |_| {
|
||||
// finally
|
||||
ok(())
|
||||
})
|
||||
})?,
|
||||
)
|
||||
})
|
||||
.with_context(|| format!("while binding to the port {}", config.ldap_port))
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
use crate::infra::configuration::Configuration;
|
||||
use anyhow::Context;
|
||||
use tracing::subscriber::set_global_default;
|
||||
use tracing_log::LogTracer;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
pub fn init(config: Configuration) -> anyhow::Result<()> {
|
||||
pub fn init(config: &Configuration) -> anyhow::Result<()> {
|
||||
let max_log_level = log_level_from_config(config);
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_timer(tracing_subscriber::fmt::time::time())
|
||||
.with_target(false)
|
||||
.with_level(true)
|
||||
.with_max_level(max_log_level)
|
||||
.finish();
|
||||
LogTracer::init().context("Failed to set logger")?;
|
||||
set_global_default(subscriber).context("Failed to set subscriber")?;
|
||||
let sqlx_max_log_level = sqlx_log_level_from_config(config);
|
||||
let filter = tracing_subscriber::filter::Targets::new()
|
||||
.with_target("lldap", max_log_level)
|
||||
.with_target("sqlx", sqlx_max_log_level);
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer().with_filter(filter))
|
||||
.init();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_level_from_config(config: Configuration) -> tracing::Level {
|
||||
fn log_level_from_config(config: &Configuration) -> tracing::Level {
|
||||
if config.verbose {
|
||||
tracing::Level::DEBUG
|
||||
} else {
|
||||
tracing::Level::INFO
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlx_log_level_from_config(config: &Configuration) -> tracing::Level {
|
||||
if config.verbose {
|
||||
tracing::Level::INFO
|
||||
} else {
|
||||
tracing::Level::WARN
|
||||
}
|
||||
}
|
||||
|
||||
65
server/src/infra/mail.rs
Normal file
65
server/src/infra/mail.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use crate::infra::configuration::MailOptions;
|
||||
use anyhow::Result;
|
||||
use lettre::{
|
||||
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
||||
Transport,
|
||||
};
|
||||
use log::debug;
|
||||
|
||||
fn send_email(to: Mailbox, subject: &str, body: String, options: &MailOptions) -> Result<()> {
|
||||
let from = options
|
||||
.from
|
||||
.clone()
|
||||
.unwrap_or_else(|| "LLDAP <nobody@lldap>".parse().unwrap());
|
||||
let reply_to = options.reply_to.clone().unwrap_or_else(|| from.clone());
|
||||
debug!(
|
||||
"Sending email to '{}' as '{}' via '{}'@'{}':'{}'",
|
||||
&to, &from, &options.user, &options.server, options.port
|
||||
);
|
||||
let email = Message::builder()
|
||||
.from(from)
|
||||
.reply_to(reply_to)
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.body(body)?;
|
||||
let creds = Credentials::new(
|
||||
options.user.clone(),
|
||||
options.password.unsecure().to_string(),
|
||||
);
|
||||
let mailer = SmtpTransport::relay(&options.server)?
|
||||
.credentials(creds)
|
||||
.build();
|
||||
mailer.send(&email)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_password_reset_email(
|
||||
username: &str,
|
||||
to: &str,
|
||||
token: &str,
|
||||
domain: &str,
|
||||
options: &MailOptions,
|
||||
) -> Result<()> {
|
||||
let to = to.parse()?;
|
||||
let body = format!(
|
||||
"Hello {},
|
||||
This email has been sent to you in order to validate your identity.
|
||||
If you did not initiate the process your credentials might have been
|
||||
compromised. You should reset your password and contact an administrator.
|
||||
|
||||
To reset your password please visit the following URL: {}/reset-password/step2/{}
|
||||
|
||||
Please contact an administrator if you did not initiate the process.",
|
||||
username, domain, token
|
||||
);
|
||||
send_email(to, "[LLDAP] Password reset requested", body, options)
|
||||
}
|
||||
|
||||
pub fn send_test_email(to: Mailbox, options: &MailOptions) -> Result<()> {
|
||||
send_email(
|
||||
to,
|
||||
"LLDAP test email",
|
||||
"The test is successful! You can send emails from LLDAP".to_string(),
|
||||
options,
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod jwt_sql_tables;
|
||||
pub mod ldap_handler;
|
||||
pub mod ldap_server;
|
||||
pub mod logging;
|
||||
pub mod mail;
|
||||
pub mod sql_backend_handler;
|
||||
pub mod tcp_backend_handler;
|
||||
pub mod tcp_server;
|
||||
|
||||
@@ -6,10 +6,19 @@ use sea_query::{Expr, Iden, Query, SimpleExpr};
|
||||
use sqlx::Row;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn gen_random_string(len: usize) -> String {
|
||||
use rand::{distributions::Alphanumeric, rngs::SmallRng, Rng, SeedableRng};
|
||||
let mut rng = SmallRng::from_entropy();
|
||||
std::iter::repeat(())
|
||||
.map(|()| rng.sample(Alphanumeric))
|
||||
.map(char::from)
|
||||
.take(len)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TcpBackendHandler for SqlBackendHandler {
|
||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>> {
|
||||
use sqlx::Result;
|
||||
let query = Query::select()
|
||||
.column(JwtStorage::JwtHash)
|
||||
.from(JwtStorage::Table)
|
||||
@@ -21,21 +30,15 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
.collect::<Vec<sqlx::Result<u64>>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<HashSet<u64>>>()
|
||||
.collect::<sqlx::Result<HashSet<u64>>>()
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
}
|
||||
|
||||
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)> {
|
||||
use rand::{distributions::Alphanumeric, rngs::SmallRng, Rng, SeedableRng};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
// TODO: Initialize the rng only once. Maybe Arc<Cell>?
|
||||
let mut rng = SmallRng::from_entropy();
|
||||
let refresh_token: String = std::iter::repeat(())
|
||||
.map(|()| rng.sample(Alphanumeric))
|
||||
.map(char::from)
|
||||
.take(100)
|
||||
.collect();
|
||||
let refresh_token = gen_random_string(100);
|
||||
let refresh_token_hash = {
|
||||
let mut s = DefaultHasher::new();
|
||||
refresh_token.hash(&mut s);
|
||||
@@ -71,7 +74,7 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>> {
|
||||
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>> {
|
||||
use sqlx::Result;
|
||||
let query = Query::select()
|
||||
.column(JwtStorage::JwtHash)
|
||||
@@ -94,7 +97,7 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||
Ok(result?)
|
||||
}
|
||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()> {
|
||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()> {
|
||||
let query = Query::delete()
|
||||
.from_table(JwtRefreshStorage::Table)
|
||||
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash))
|
||||
@@ -102,4 +105,59 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_password_reset(&self, user: &str) -> Result<Option<String>> {
|
||||
let query = Query::select()
|
||||
.column(Users::UserId)
|
||||
.from(Users::Table)
|
||||
.and_where(Expr::col(Users::UserId).eq(user))
|
||||
.to_string(DbQueryBuilder {});
|
||||
|
||||
// Check that the user exists.
|
||||
if sqlx::query(&query).fetch_one(&self.sql_pool).await.is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let token = gen_random_string(100);
|
||||
let duration = chrono::Duration::minutes(10);
|
||||
|
||||
let query = Query::insert()
|
||||
.into_table(PasswordResetTokens::Table)
|
||||
.columns(vec![
|
||||
PasswordResetTokens::Token,
|
||||
PasswordResetTokens::UserId,
|
||||
PasswordResetTokens::ExpiryDate,
|
||||
])
|
||||
.values_panic(vec![
|
||||
token.clone().into(),
|
||||
user.into(),
|
||||
(chrono::Utc::now() + duration).naive_utc().into(),
|
||||
])
|
||||
.to_string(DbQueryBuilder {});
|
||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||
Ok(Some(token))
|
||||
}
|
||||
|
||||
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<String> {
|
||||
let query = Query::select()
|
||||
.column(PasswordResetTokens::UserId)
|
||||
.from(PasswordResetTokens::Table)
|
||||
.and_where(Expr::col(PasswordResetTokens::Token).eq(token))
|
||||
.and_where(
|
||||
Expr::col(PasswordResetTokens::ExpiryDate).gt(chrono::Utc::now().naive_utc()),
|
||||
)
|
||||
.to_string(DbQueryBuilder {});
|
||||
|
||||
let (user_id,) = sqlx::query_as(&query).fetch_one(&self.sql_pool).await?;
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
async fn delete_password_reset_token(&self, token: &str) -> Result<()> {
|
||||
let query = Query::delete()
|
||||
.from_table(PasswordResetTokens::Table)
|
||||
.and_where(Expr::col(PasswordResetTokens::Token).eq(token))
|
||||
.to_string(DbQueryBuilder {});
|
||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub type DomainResult<T> = crate::domain::error::Result<T>;
|
||||
use crate::domain::error::Result;
|
||||
|
||||
#[async_trait]
|
||||
pub trait TcpBackendHandler {
|
||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
||||
async fn create_refresh_token(&self, user: &str) -> DomainResult<(String, chrono::Duration)>;
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> DomainResult<bool>;
|
||||
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>>;
|
||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()>;
|
||||
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)>;
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
|
||||
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>>;
|
||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()>;
|
||||
|
||||
/// Request a token to reset a user's password.
|
||||
/// If the user doesn't exist, returns `Ok(None)`, otherwise `Ok(Some(token))`.
|
||||
async fn start_password_reset(&self, user: &str) -> Result<Option<String>>;
|
||||
|
||||
/// Get the user ID associated with a password reset token.
|
||||
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<String>;
|
||||
|
||||
async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -22,30 +31,33 @@ mockall::mock! {
|
||||
}
|
||||
#[async_trait]
|
||||
impl LoginHandler for TestTcpBackendHandler {
|
||||
async fn bind(&self, request: BindRequest) -> DomainResult<()>;
|
||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||
}
|
||||
#[async_trait]
|
||||
impl BackendHandler for TestTcpBackendHandler {
|
||||
async fn list_users(&self, filters: Option<RequestFilter>) -> DomainResult<Vec<User>>;
|
||||
async fn list_groups(&self) -> DomainResult<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &str) -> DomainResult<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> DomainResult<GroupIdAndName>;
|
||||
async fn get_user_groups(&self, user: &str) -> DomainResult<HashSet<GroupIdAndName>>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> DomainResult<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> DomainResult<()>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> DomainResult<()>;
|
||||
async fn delete_user(&self, user_id: &str) -> DomainResult<()>;
|
||||
async fn create_group(&self, group_name: &str) -> DomainResult<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> DomainResult<()>;
|
||||
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> DomainResult<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> DomainResult<()>;
|
||||
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
|
||||
async fn list_groups(&self) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &str) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
|
||||
async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &str) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
}
|
||||
#[async_trait]
|
||||
impl TcpBackendHandler for TestTcpBackendHandler {
|
||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
||||
async fn create_refresh_token(&self, user: &str) -> DomainResult<(String, chrono::Duration)>;
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> DomainResult<bool>;
|
||||
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>>;
|
||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()>;
|
||||
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)>;
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
|
||||
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>>;
|
||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()>;
|
||||
async fn start_password_reset(&self, user: &str) -> Result<Option<String>>;
|
||||
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<String>;
|
||||
async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ use crate::{
|
||||
handler::{BackendHandler, LoginHandler},
|
||||
opaque_handler::OpaqueHandler,
|
||||
},
|
||||
infra::{auth_service, configuration::Configuration, tcp_backend_handler::*},
|
||||
infra::{
|
||||
auth_service,
|
||||
configuration::{Configuration, MailOptions},
|
||||
tcp_backend_handler::*,
|
||||
},
|
||||
};
|
||||
use actix_files::{Files, NamedFile};
|
||||
use actix_http::HttpServiceBuilder;
|
||||
@@ -44,15 +48,19 @@ pub(crate) fn error_to_http_response(error: DomainError) -> HttpResponse {
|
||||
fn http_config<Backend>(
|
||||
cfg: &mut web::ServiceConfig,
|
||||
backend_handler: Backend,
|
||||
jwt_secret: String,
|
||||
jwt_secret: secstr::SecUtf8,
|
||||
jwt_blacklist: HashSet<u64>,
|
||||
server_url: String,
|
||||
mail_options: MailOptions,
|
||||
) where
|
||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
|
||||
{
|
||||
cfg.app_data(web::Data::new(AppState::<Backend> {
|
||||
backend_handler,
|
||||
jwt_key: Hmac::new_varkey(jwt_secret.as_bytes()).unwrap(),
|
||||
jwt_key: Hmac::new_varkey(jwt_secret.unsecure().as_bytes()).unwrap(),
|
||||
jwt_blacklist: RwLock::new(jwt_blacklist),
|
||||
server_url,
|
||||
mail_options,
|
||||
}))
|
||||
// Serve index.html and main.js, and default to index.html.
|
||||
.route(
|
||||
@@ -76,6 +84,8 @@ pub(crate) struct AppState<Backend> {
|
||||
pub backend_handler: Backend,
|
||||
pub jwt_key: Hmac<Sha512>,
|
||||
pub jwt_blacklist: RwLock<HashSet<u64>>,
|
||||
pub server_url: String,
|
||||
pub mail_options: MailOptions,
|
||||
}
|
||||
|
||||
pub async fn build_tcp_server<Backend>(
|
||||
@@ -87,16 +97,30 @@ where
|
||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
|
||||
{
|
||||
let jwt_secret = config.jwt_secret.clone();
|
||||
let jwt_blacklist = backend_handler.get_jwt_blacklist().await?;
|
||||
let jwt_blacklist = backend_handler
|
||||
.get_jwt_blacklist()
|
||||
.await
|
||||
.context("while getting the jwt blacklist")?;
|
||||
let server_url = config.http_url.clone();
|
||||
let mail_options = config.smtp_options.clone();
|
||||
server_builder
|
||||
.bind("http", ("0.0.0.0", config.http_port), move || {
|
||||
let backend_handler = backend_handler.clone();
|
||||
let jwt_secret = jwt_secret.clone();
|
||||
let jwt_blacklist = jwt_blacklist.clone();
|
||||
let server_url = server_url.clone();
|
||||
let mail_options = mail_options.clone();
|
||||
HttpServiceBuilder::new()
|
||||
.finish(map_config(
|
||||
App::new().configure(move |cfg| {
|
||||
http_config(cfg, backend_handler, jwt_secret, jwt_blacklist)
|
||||
http_config(
|
||||
cfg,
|
||||
backend_handler,
|
||||
jwt_secret,
|
||||
jwt_blacklist,
|
||||
server_url,
|
||||
mail_options,
|
||||
)
|
||||
}),
|
||||
|_| AppConfig::default(),
|
||||
))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::nonstandard_macro_braces)]
|
||||
|
||||
use crate::{
|
||||
@@ -8,7 +9,7 @@ use crate::{
|
||||
sql_opaque_handler::register_password,
|
||||
sql_tables::PoolOptions,
|
||||
},
|
||||
infra::{cli::*, configuration::Configuration, db_cleaner::Scheduler},
|
||||
infra::{cli::*, configuration::Configuration, db_cleaner::Scheduler, mail},
|
||||
};
|
||||
use actix::Actor;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
@@ -19,10 +20,11 @@ mod domain;
|
||||
mod infra;
|
||||
|
||||
async fn create_admin_user(handler: &SqlBackendHandler, config: &Configuration) -> Result<()> {
|
||||
let pass_length = config.ldap_user_pass.unsecure().len();
|
||||
assert!(
|
||||
config.ldap_user_pass.len() >= 8,
|
||||
pass_length >= 8,
|
||||
"Minimum password length is 8 characters, got {} characters",
|
||||
config.ldap_user_pass.len()
|
||||
pass_length
|
||||
);
|
||||
handler
|
||||
.create_user(CreateUserRequest {
|
||||
@@ -47,51 +49,69 @@ async fn run_server(config: Configuration) -> Result<()> {
|
||||
let sql_pool = PoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&config.database_url)
|
||||
.await?;
|
||||
domain::sql_tables::init_table(&sql_pool).await?;
|
||||
.await
|
||||
.context("while connecting to the DB")?;
|
||||
domain::sql_tables::init_table(&sql_pool)
|
||||
.await
|
||||
.context("while creating the tables")?;
|
||||
let backend_handler = SqlBackendHandler::new(config.clone(), sql_pool.clone());
|
||||
if let Err(e) = backend_handler.get_user_details(&config.ldap_user_dn).await {
|
||||
warn!("Could not get admin user, trying to create it: {:#}", e);
|
||||
create_admin_user(&backend_handler, &config)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Error setting up admin login/account: {:#}", e))?;
|
||||
.map_err(|e| anyhow!("Error setting up admin login/account: {:#}", e))
|
||||
.context("while creating the admin user")?;
|
||||
}
|
||||
let server_builder = infra::ldap_server::build_ldap_server(
|
||||
&config,
|
||||
backend_handler.clone(),
|
||||
actix_server::Server::build(),
|
||||
)?;
|
||||
)
|
||||
.context("while binding the LDAP server")?;
|
||||
infra::jwt_sql_tables::init_table(&sql_pool).await?;
|
||||
let server_builder =
|
||||
infra::tcp_server::build_tcp_server(&config, backend_handler, server_builder).await?;
|
||||
infra::tcp_server::build_tcp_server(&config, backend_handler, server_builder)
|
||||
.await
|
||||
.context("while binding the TCP server")?;
|
||||
// Run every hour.
|
||||
let scheduler = Scheduler::new("0 0 * * * * *", sql_pool);
|
||||
scheduler.start();
|
||||
server_builder.workers(1).run().await?;
|
||||
server_builder
|
||||
.workers(1)
|
||||
.run()
|
||||
.await
|
||||
.context("while starting the server")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_server_command(opts: RunOpts) -> Result<()> {
|
||||
let config = infra::configuration::init(opts.clone())?;
|
||||
infra::logging::init(config.clone())?;
|
||||
debug!("CLI: {:#?}", &opts);
|
||||
|
||||
let config = infra::configuration::init(opts)?;
|
||||
infra::logging::init(&config)?;
|
||||
|
||||
info!("Starting LLDAP....");
|
||||
|
||||
debug!("CLI: {:#?}", opts);
|
||||
debug!("Configuration: {:#?}", config);
|
||||
|
||||
actix::run(
|
||||
run_server(config).unwrap_or_else(|e| error!("Could not bring up the servers: {:?}", e)),
|
||||
run_server(config).unwrap_or_else(|e| error!("Could not bring up the servers: {:#}", e)),
|
||||
)?;
|
||||
|
||||
info!("End.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_test_email_command(opts: TestEmailOpts) -> Result<()> {
|
||||
let to = opts.to.parse()?;
|
||||
let config = infra::configuration::init(opts)?;
|
||||
infra::logging::init(&config)?;
|
||||
mail::send_test_email(to, &config.smtp_options)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli_opts = infra::cli::init();
|
||||
match cli_opts.command {
|
||||
Command::ExportGraphQLSchema(opts) => infra::graphql::api::export_schema(opts),
|
||||
Command::Run(opts) => run_server_command(opts),
|
||||
Command::SendTestEmail(opts) => send_test_email_command(opts),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user