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
|
# Don't track git
|
||||||
.git/*
|
.git/*
|
||||||
|
.github/*
|
||||||
|
.gitignore
|
||||||
|
|
||||||
# Don't track cargo generated files
|
# Don't track cargo generated files
|
||||||
target/*
|
target/*
|
||||||
@@ -18,5 +20,6 @@ Dockerfile
|
|||||||
lldap_config.toml
|
lldap_config.toml
|
||||||
server_key
|
server_key
|
||||||
users.db*
|
users.db*
|
||||||
.gitignore
|
|
||||||
screenshot.png
|
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:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
- uses: Swatinem/rust-cache@v1
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose --workspace
|
run: cargo build --verbose --workspace
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
@@ -42,6 +43,8 @@ jobs:
|
|||||||
override: true
|
override: true
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v1
|
||||||
|
|
||||||
- name: Run cargo clippy
|
- name: Run cargo clippy
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@v1
|
||||||
with:
|
with:
|
||||||
@@ -63,8 +66,37 @@ jobs:
|
|||||||
override: true
|
override: true
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v1
|
||||||
|
|
||||||
- name: Run cargo fmt
|
- name: Run cargo fmt
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@v1
|
||||||
with:
|
with:
|
||||||
command: fmt
|
command: fmt
|
||||||
args: --all -- --check
|
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 private key
|
||||||
server_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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
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]]
|
[[package]]
|
||||||
name = "blake2b_simd"
|
name = "blake2b_simd"
|
||||||
version = "0.5.11"
|
version = "0.5.11"
|
||||||
@@ -997,6 +1009,15 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b394ed3d285a429378d3b384b9eb1285267e7df4b166df24b7a6939a04dc392e"
|
||||||
|
dependencies = [
|
||||||
|
"instant",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figment"
|
name = "figment"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -1069,6 +1090,12 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69a039c3498dc930fe810151a34ba0c1c70b02b8625035592e74432f678591f2"
|
checksum = "69a039c3498dc930fe810151a34ba0c1c70b02b8625035592e74432f678591f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "funty"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -1443,6 +1470,17 @@ dependencies = [
|
|||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -1466,6 +1504,12 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503"
|
checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -1644,15 +1688,41 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ldap3_server"
|
name = "ldap3_server"
|
||||||
version = "0.1.7"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3beb05c22d6cb1792389efb3e71ed90af6148b6f26d283db67322d356ab2556d"
|
checksum = "092da326ef499380e33fc8213a621de7fb342d6cd112eb695e16161a0acb061a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"lber",
|
"lber",
|
||||||
"tokio-util",
|
"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]]
|
[[package]]
|
||||||
name = "lexical-core"
|
name = "lexical-core"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@@ -1697,7 +1767,7 @@ checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lldap"
|
name = "lldap"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-files",
|
"actix-files",
|
||||||
@@ -1725,13 +1795,16 @@ dependencies = [
|
|||||||
"juniper_actix",
|
"juniper_actix",
|
||||||
"jwt",
|
"jwt",
|
||||||
"ldap3_server",
|
"ldap3_server",
|
||||||
|
"lettre",
|
||||||
"lldap_auth",
|
"lldap_auth",
|
||||||
"log",
|
"log",
|
||||||
"mockall",
|
"mockall",
|
||||||
"opaque-ke",
|
"opaque-ke",
|
||||||
|
"openssl-sys",
|
||||||
"orion",
|
"orion",
|
||||||
"rand 0.8.4",
|
"rand 0.8.4",
|
||||||
"sea-query",
|
"sea-query",
|
||||||
|
"secstr",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -1740,6 +1813,7 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
"time 0.2.27",
|
"time 0.2.27",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-actix-web",
|
"tracing-actix-web",
|
||||||
@@ -1749,7 +1823,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lldap_app"
|
name = "lldap_app"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1773,7 +1847,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lldap_auth"
|
name = "lldap_auth"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
@@ -1785,8 +1859,6 @@ dependencies = [
|
|||||||
"rust-argon2",
|
"rust-argon2",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
|
||||||
"sqlx-core",
|
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1833,13 +1905,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "match_cfg"
|
||||||
version = "0.0.1"
|
version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f099785f7595cc4b4553a174ce30dd7589ef93391ff414dbb67f62392b9e0ce1"
|
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
|
||||||
dependencies = [
|
|
||||||
"regex-automata",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matches"
|
name = "matches"
|
||||||
@@ -1989,6 +2058,18 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.0.0"
|
version = "7.0.0"
|
||||||
@@ -2119,8 +2200,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-ke"
|
name = "opaque-ke"
|
||||||
version = "0.6.0-pre.1"
|
version = "0.6.0"
|
||||||
source = "git+https://github.com/novifinancial/opaque-ke?rev=eb59676a940b15f77871aefe1e46d7b5bf85f40a#eb59676a940b15f77871aefe1e46d7b5bf85f40a"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26772682ba4fa69f11ae6e4af8bc83946372981ff31a026648d4acb2553c9ee8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
@@ -2157,6 +2239,15 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
|
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]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.66"
|
version = "0.9.66"
|
||||||
@@ -2166,6 +2257,7 @@ dependencies = [
|
|||||||
"autocfg 1.0.1",
|
"autocfg 1.0.1",
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
|
"openssl-src",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
@@ -2408,6 +2500,29 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"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]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -2509,15 +2624,6 @@ dependencies = [
|
|||||||
"regex-syntax",
|
"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]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.6.25"
|
version = "0.6.25"
|
||||||
@@ -2607,6 +2713,15 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2635,6 +2750,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secstr"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cce2c726741c320e5b8f1edd9a21b3c2c292ae94514afd001d41d81ba143dafc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "2.4.2"
|
version = "2.4.2"
|
||||||
@@ -3049,6 +3174,12 @@ dependencies = [
|
|||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tap"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.2.0"
|
version = "3.2.0"
|
||||||
@@ -3286,9 +3417,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.19"
|
version = "0.1.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ca517f43f0fb96e0c3072ed5c275fe5eece87e8cb52f4a77b69226d3b1c9df8"
|
checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
@@ -3314,36 +3445,18 @@ dependencies = [
|
|||||||
"tracing-core",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.2.20"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9cbe87a2fa7e35900ce5de20220a582a9483a7063811defce79d7cbd59d4cfe"
|
checksum = "80a4ddde70311d8da398062ecf6fc2c309337de6b0f77d6c27aff8d53f6fca52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ansi_term",
|
"ansi_term",
|
||||||
"chrono",
|
|
||||||
"lazy_static",
|
|
||||||
"matchers",
|
|
||||||
"regex",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
"tracing",
|
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
"tracing-serde",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3648,6 +3761,12 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wyz"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yansi"
|
name = "yansi"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|||||||
57
Dockerfile
57
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Build image
|
# Build image
|
||||||
FROM rust:alpine AS chef
|
FROM rust:alpine3.14 AS chef
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
# Add user
|
# Add user
|
||||||
@@ -9,12 +9,13 @@ RUN set -x \
|
|||||||
--ingroup app \
|
--ingroup app \
|
||||||
--home /app \
|
--home /app \
|
||||||
--uid 10001 \
|
--uid 10001 \
|
||||||
app
|
app \
|
||||||
RUN set -x \
|
|
||||||
# Install required packages
|
# Install required packages
|
||||||
&& apk add npm openssl-dev musl-dev make perl
|
&& apk add npm openssl-dev musl-dev make perl curl
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
# Install build tools
|
# Install build tools
|
||||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||||
@@ -24,44 +25,38 @@ RUN set -x \
|
|||||||
# Prepare the dependency list.
|
# Prepare the dependency list.
|
||||||
FROM chef AS planner
|
FROM chef AS planner
|
||||||
COPY . .
|
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
|
FROM chef AS builder
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /tmp/recipe.json recipe.json
|
||||||
RUN cargo chef cook --release -p lldap --recipe-path recipe.json \
|
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||||
&& 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 . .
|
COPY --chown=app:app . .
|
||||||
RUN cargo build --release -p lldap
|
RUN cargo build --release -p lldap \
|
||||||
# TODO: release mode.
|
# Build the frontend.
|
||||||
RUN ./app/build.sh
|
&& ./app/build.sh
|
||||||
|
|
||||||
# Final image
|
# 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 \
|
RUN set -x \
|
||||||
# Add user
|
&& apk add --no-cache bash \
|
||||||
&& addgroup --gid 10001 app \
|
&& chmod a+r -R .
|
||||||
&& 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
|
|
||||||
|
|
||||||
ENV LDAP_PORT=3890
|
ENV LDAP_PORT=3890
|
||||||
ENV HTTP_PORT=17170
|
ENV HTTP_PORT=17170
|
||||||
|
|
||||||
EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
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
|
<p align="center">
|
||||||
implemented but still very rough. For updates, follow
|
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||||
[@nitnelave1](https://twitter.com/nitnelave1) or join our [Discord
|
<img
|
||||||
server](https://discord.gg/h5PEdRMNyP)!
|
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
|
This project is a lightweight authentication server that provides an
|
||||||
opinionated, simplified LDAP interface for authentication: clients that can
|
opinionated, simplified LDAP interface for authentication. It integrates with
|
||||||
only speak LDAP protocol can talk to it and use it as an authentication server.
|
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,
|
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
||||||
check out OpenLDAP. This server is a user management system that is:
|
check out OpenLDAP. This server is a user management system that is:
|
||||||
* simple to setup (no messing around with `slapd`)
|
* simple to setup (no messing around with `slapd`),
|
||||||
* simple to manage (friendly web UI)
|
* simple to manage (friendly web UI),
|
||||||
|
* low resources,
|
||||||
* opinionated with basic defaults so you don't have to understand the
|
* opinionated with basic defaults so you don't have to understand the
|
||||||
subtleties of LDAP.
|
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
|
other components (KeyCloak, Authelia, ...) using this server as the source of
|
||||||
truth for users, via LDAP.
|
truth for users, via LDAP.
|
||||||
|
|
||||||
## Setup
|
## Installation
|
||||||
|
|
||||||
### With Docker
|
### 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
|
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||||
`jwt_secret` and `ldap_user_pass`, unless you override them with env variables).
|
`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:
|
Example for docker compose:
|
||||||
|
|
||||||
@@ -53,6 +83,8 @@ volumes:
|
|||||||
services:
|
services:
|
||||||
lldap:
|
lldap:
|
||||||
image: nitnelave/lldap
|
image: nitnelave/lldap
|
||||||
|
# Change this to the user:group you want.
|
||||||
|
user: "33:33"
|
||||||
ports:
|
ports:
|
||||||
# For LDAP
|
# For LDAP
|
||||||
- "3890:3890"
|
- "3890:3890"
|
||||||
@@ -60,10 +92,12 @@ services:
|
|||||||
- "17170:17170"
|
- "17170:17170"
|
||||||
volumes:
|
volumes:
|
||||||
- "lldap_data:/data"
|
- "lldap_data:/data"
|
||||||
|
# Alternatively, you can mount a local folder
|
||||||
|
# - "./lldap_data:/data"
|
||||||
environment:
|
environment:
|
||||||
- JWT_SECRET=REPLACE_WITH_RANDOM
|
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||||
- LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||||
- LDAP_BASE_DN=dc=example,dc=com
|
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||||
```
|
```
|
||||||
|
|
||||||
Then the service will listen on two ports, one for LDAP and one for the web
|
Then the service will listen on two ports, one for LDAP and one for the web
|
||||||
@@ -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
|
`lldap_config.toml`, setting environment variables or passing arguments to
|
||||||
`cargo run`.
|
`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
|
## Client configuration
|
||||||
|
|
||||||
To configure the services that will talk to LLDAP, here are the values:
|
To configure the services that will talk to LLDAP, here are the values:
|
||||||
- The LDAP user DN is from the configuration. By default,
|
- The LDAP user DN is from the configuration. By default,
|
||||||
`cn=admin,dc=example,dc=com`.
|
`cn=admin,ou=people,dc=example,dc=com`.
|
||||||
- The LDAP password is from the configuration (same as to log in to the web
|
- The LDAP password is from the configuration (same as to log in to the web
|
||||||
UI).
|
UI).
|
||||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
- 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
|
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
||||||
admin rights in the Web UI.
|
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!
|
## I can't log in!
|
||||||
|
|
||||||
If you just set up the server, can get to the login page but the password you
|
If you just set up the server, can get to the login page but the password you
|
||||||
@@ -122,90 +221,6 @@ set isn't working, try the following:
|
|||||||
- Make sure you restart the server.
|
- Make sure you restart the server.
|
||||||
- If it's still not working, join the [Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
- 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
|
||||||
|
|
||||||
Contributions are welcome! Just fork and open a PR. Or just file a bug.
|
Contributions are welcome! Just fork and open a PR. Or just file a bug.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lldap_app"
|
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>"]
|
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||||
edition = "2018"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::select::{Select, SelectOption, SelectOptionProps},
|
components::select::{Select, SelectOption, SelectOptionProps},
|
||||||
infra::api::HostService,
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use yew::{
|
use yew::prelude::*;
|
||||||
prelude::*,
|
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
|
||||||
};
|
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -33,14 +29,11 @@ pub struct ListUserNames;
|
|||||||
pub type User = list_user_names::ListUserNamesUsers;
|
pub type User = list_user_names::ListUserNamesUsers;
|
||||||
|
|
||||||
pub struct AddGroupMemberComponent {
|
pub struct AddGroupMemberComponent {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
props: Props,
|
|
||||||
/// The list of existing users, initially not loaded.
|
/// The list of existing users, initially not loaded.
|
||||||
user_list: Option<Vec<User>>,
|
user_list: Option<Vec<User>>,
|
||||||
/// The currently selected user.
|
/// The currently selected user.
|
||||||
selected_user: Option<User>,
|
selected_user: Option<User>,
|
||||||
// Used to keep the request alive long enough.
|
|
||||||
task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
@@ -58,58 +51,24 @@ pub struct Props {
|
|||||||
pub on_error: Callback<Error>,
|
pub on_error: Callback<Error>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddGroupMemberComponent {
|
impl CommonComponent<AddGroupMemberComponent> for 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserListResponse(response) => {
|
Msg::UserListResponse(response) => {
|
||||||
self.user_list = Some(response?.users);
|
self.user_list = Some(response?.users);
|
||||||
self.task = None;
|
self.common.cancel_task();
|
||||||
}
|
}
|
||||||
Msg::SubmitAddMember => return self.submit_add_member(),
|
Msg::SubmitAddMember => return self.submit_add_member(),
|
||||||
Msg::AddMemberResponse(response) => {
|
Msg::AddMemberResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.task = None;
|
self.common.cancel_task();
|
||||||
let user = self
|
let user = self
|
||||||
.selected_user
|
.selected_user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("Could not get selected user")
|
.expect("Could not get selected user")
|
||||||
.clone();
|
.clone();
|
||||||
// Remove the user from the dropdown.
|
// Remove the user from the dropdown.
|
||||||
self.props.on_user_added_to_group.emit(user);
|
self.common.on_user_added_to_group.emit(user);
|
||||||
}
|
}
|
||||||
Msg::SelectionChanged(option_props) => {
|
Msg::SelectionChanged(option_props) => {
|
||||||
let was_some = self.selected_user.is_some();
|
let was_some = self.selected_user.is_some();
|
||||||
@@ -123,8 +82,38 @@ impl AddGroupMemberComponent {
|
|||||||
Ok(true)
|
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> {
|
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
|
user_list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|u| !user_groups.contains(u))
|
.filter(|u| !user_groups.contains(u))
|
||||||
@@ -136,32 +125,27 @@ impl AddGroupMemberComponent {
|
|||||||
impl Component for AddGroupMemberComponent {
|
impl Component for AddGroupMemberComponent {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
let mut res = Self {
|
let mut res = Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
props,
|
|
||||||
user_list: None,
|
user_list: None,
|
||||||
selected_user: None,
|
selected_user: None,
|
||||||
task: None,
|
|
||||||
};
|
};
|
||||||
res.get_user_list();
|
res.get_user_list();
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
match self.handle_msg(msg) {
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
Err(e) => {
|
self,
|
||||||
ConsoleService::error(&e.to_string());
|
msg,
|
||||||
self.props.on_error.emit(e);
|
self.common.on_error.clone(),
|
||||||
self.task = None;
|
)
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||||
self.props.neq_assign(props)
|
self.common.change(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
fn view(&self) -> Html {
|
||||||
@@ -176,7 +160,7 @@ impl Component for AddGroupMemberComponent {
|
|||||||
html! {
|
html! {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3">
|
<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
|
to_add_user_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -188,8 +172,8 @@ impl Component for AddGroupMemberComponent {
|
|||||||
<div class="col-sm-1">
|
<div class="col-sm-1">
|
||||||
<button
|
<button
|
||||||
class="btn btn-success"
|
class="btn btn-success"
|
||||||
disabled=self.selected_user.is_none() || self.task.is_some()
|
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
||||||
onclick=self.link.callback(|_| Msg::SubmitAddMember)>
|
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
||||||
{"Add"}
|
{"Add"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,16 +3,12 @@ use crate::{
|
|||||||
select::{Select, SelectOption, SelectOptionProps},
|
select::{Select, SelectOption, SelectOptionProps},
|
||||||
user_details::Group,
|
user_details::Group,
|
||||||
},
|
},
|
||||||
infra::api::HostService,
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use yew::{
|
use yew::prelude::*;
|
||||||
prelude::*,
|
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
|
||||||
};
|
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -45,14 +41,11 @@ impl From<GroupListGroup> for Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AddUserToGroupComponent {
|
pub struct AddUserToGroupComponent {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
props: Props,
|
|
||||||
/// The list of existing groups, initially not loaded.
|
/// The list of existing groups, initially not loaded.
|
||||||
group_list: Option<Vec<Group>>,
|
group_list: Option<Vec<Group>>,
|
||||||
/// The currently selected group.
|
/// The currently selected group.
|
||||||
selected_group: Option<Group>,
|
selected_group: Option<Group>,
|
||||||
// Used to keep the request alive long enough.
|
|
||||||
task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
@@ -70,51 +63,17 @@ pub struct Props {
|
|||||||
pub on_error: Callback<Error>,
|
pub on_error: Callback<Error>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AddUserToGroupComponent {
|
impl CommonComponent<AddUserToGroupComponent> for 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::GroupListResponse(response) => {
|
Msg::GroupListResponse(response) => {
|
||||||
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
||||||
self.task = None;
|
self.common.cancel_task();
|
||||||
}
|
}
|
||||||
Msg::SubmitAddGroup => return self.submit_add_group(),
|
Msg::SubmitAddGroup => return self.submit_add_group(),
|
||||||
Msg::AddGroupResponse(response) => {
|
Msg::AddGroupResponse(response) => {
|
||||||
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
|
// Adding the user to the group succeeded, we're not in the process of adding a
|
||||||
// group anymore.
|
// group anymore.
|
||||||
let group = self
|
let group = self
|
||||||
@@ -123,7 +82,7 @@ impl AddUserToGroupComponent {
|
|||||||
.expect("Could not get selected group")
|
.expect("Could not get selected group")
|
||||||
.clone();
|
.clone();
|
||||||
// Remove the group from the dropdown.
|
// Remove the group from the dropdown.
|
||||||
self.props.on_user_added_to_group.emit(group);
|
self.common.on_user_added_to_group.emit(group);
|
||||||
}
|
}
|
||||||
Msg::SelectionChanged(option_props) => {
|
Msg::SelectionChanged(option_props) => {
|
||||||
let was_some = self.selected_group.is_some();
|
let was_some = self.selected_group.is_some();
|
||||||
@@ -137,8 +96,38 @@ impl AddUserToGroupComponent {
|
|||||||
Ok(true)
|
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> {
|
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
|
group_list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|g| !user_groups.contains(g))
|
.filter(|g| !user_groups.contains(g))
|
||||||
@@ -152,29 +141,24 @@ impl Component for AddUserToGroupComponent {
|
|||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
let mut res = Self {
|
let mut res = Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
props,
|
|
||||||
group_list: None,
|
group_list: None,
|
||||||
selected_group: None,
|
selected_group: None,
|
||||||
task: None,
|
|
||||||
};
|
};
|
||||||
res.get_group_list();
|
res.get_group_list();
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
match self.handle_msg(msg) {
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
Err(e) => {
|
self,
|
||||||
ConsoleService::error(&e.to_string());
|
msg,
|
||||||
self.props.on_error.emit(e);
|
self.common.on_error.clone(),
|
||||||
self.task = None;
|
)
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||||
self.props.neq_assign(props)
|
self.common.change(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
fn view(&self) -> Html {
|
||||||
@@ -189,7 +173,7 @@ impl Component for AddUserToGroupComponent {
|
|||||||
html! {
|
html! {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3">
|
<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
|
to_add_group_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -201,8 +185,8 @@ impl Component for AddUserToGroupComponent {
|
|||||||
<div class="col-sm-1">
|
<div class="col-sm-1">
|
||||||
<button
|
<button
|
||||||
class="btn btn-success"
|
class="btn btn-success"
|
||||||
disabled=self.selected_group.is_none() || self.task.is_some()
|
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
||||||
onclick=self.link.callback(|_| Msg::SubmitAddGroup)>
|
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
||||||
{"Add"}
|
{"Add"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use crate::{
|
|||||||
group_table::GroupTable,
|
group_table::GroupTable,
|
||||||
login::LoginForm,
|
login::LoginForm,
|
||||||
logout::LogoutButton,
|
logout::LogoutButton,
|
||||||
|
reset_password_step1::ResetPasswordStep1Form,
|
||||||
|
reset_password_step2::ResetPasswordStep2Form,
|
||||||
router::{AppRoute, Link, NavButton},
|
router::{AppRoute, Link, NavButton},
|
||||||
user_details::UserDetails,
|
user_details::UserDetails,
|
||||||
user_table::UserTable,
|
user_table::UserTable,
|
||||||
@@ -101,40 +103,7 @@ impl Component for App {
|
|||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="shadow-sm py-3" style="max-width: 1000px">
|
<div class="shadow-sm py-3" style="max-width: 1000px">
|
||||||
<Router<AppRoute>
|
<Router<AppRoute>
|
||||||
render = Router::render(move |switch: AppRoute| {
|
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
||||||
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 />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +116,11 @@ impl App {
|
|||||||
fn get_redirect_route() -> Option<AppRoute> {
|
fn get_redirect_route() -> Option<AppRoute> {
|
||||||
let route_service = RouteService::<()>::new();
|
let route_service = RouteService::<()>::new();
|
||||||
let current_route = route_service.get_path();
|
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
|
None
|
||||||
} else {
|
} else {
|
||||||
use yew_router::Switch;
|
use yew_router::Switch;
|
||||||
@@ -156,6 +129,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply_initial_redirections(&mut self) {
|
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 {
|
match &self.user_info {
|
||||||
None => {
|
None => {
|
||||||
self.route_dispatcher
|
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 {
|
fn view_banner(&self) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<header class="p-3 mb-4 border-bottom shadow-sm">
|
<header class="p-3 mb-4 border-bottom shadow-sm">
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::router::{AppRoute, NavButton},
|
||||||
infra::api::HostService,
|
infra::{
|
||||||
|
api::HostService,
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::{
|
use yew::{prelude::*, services::ConsoleService};
|
||||||
prelude::*,
|
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
|
||||||
};
|
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{
|
||||||
@@ -58,13 +58,9 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct ChangePasswordForm {
|
pub struct ChangePasswordForm {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
props: Props,
|
|
||||||
error: Option<anyhow::Error>,
|
|
||||||
form: Form<FormModel>,
|
form: Form<FormModel>,
|
||||||
opaque_data: OpaqueData,
|
opaque_data: OpaqueData,
|
||||||
// Used to keep the request alive long enough.
|
|
||||||
task: Option<FetchTask>,
|
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
route_dispatcher: RouteAgentDispatcher,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,25 +79,16 @@ pub enum Msg {
|
|||||||
RegistrationFinishResponse(Result<()>),
|
RegistrationFinishResponse(Result<()>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChangePasswordForm {
|
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||||
fn call_backend<M, Req, C, Resp>(&mut self, method: M, req: Req, callback: C) -> Result<()>
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
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> {
|
|
||||||
match msg {
|
match msg {
|
||||||
Msg::FormUpdate => Ok(true),
|
Msg::FormUpdate => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
bail!("Check the form for errors");
|
bail!("Check the form for errors");
|
||||||
}
|
}
|
||||||
if self.props.is_admin {
|
if self.common.is_admin {
|
||||||
self.handle_message(Msg::SubmitNewPassword)
|
self.handle_msg(Msg::SubmitNewPassword)
|
||||||
} else {
|
} else {
|
||||||
let old_password = self.form.model().old_password;
|
let old_password = self.form.model().old_password;
|
||||||
if old_password.is_empty() {
|
if old_password.is_empty() {
|
||||||
@@ -113,10 +100,10 @@ impl ChangePasswordForm {
|
|||||||
.context("Could not initialize login")?;
|
.context("Could not initialize login")?;
|
||||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||||
let req = login::ClientLoginStartRequest {
|
let req = login::ClientLoginStartRequest {
|
||||||
username: self.props.username.clone(),
|
username: self.common.username.clone(),
|
||||||
login_start_request: login_start_request.message,
|
login_start_request: login_start_request.message,
|
||||||
};
|
};
|
||||||
self.call_backend(
|
self.common.call_backend(
|
||||||
HostService::login_start,
|
HostService::login_start,
|
||||||
req,
|
req,
|
||||||
Msg::AuthenticationStartResponse,
|
Msg::AuthenticationStartResponse,
|
||||||
@@ -142,7 +129,7 @@ impl ChangePasswordForm {
|
|||||||
}
|
}
|
||||||
_ => panic!("Unexpected data in opaque_data field"),
|
_ => panic!("Unexpected data in opaque_data field"),
|
||||||
};
|
};
|
||||||
self.handle_message(Msg::SubmitNewPassword)
|
self.handle_msg(Msg::SubmitNewPassword)
|
||||||
}
|
}
|
||||||
Msg::SubmitNewPassword => {
|
Msg::SubmitNewPassword => {
|
||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
@@ -151,11 +138,11 @@ impl ChangePasswordForm {
|
|||||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||||
.context("Could not initiate password change")?;
|
.context("Could not initiate password change")?;
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: self.props.username.clone(),
|
username: self.common.username.clone(),
|
||||||
registration_start_request: registration_start_request.message,
|
registration_start_request: registration_start_request.message,
|
||||||
};
|
};
|
||||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||||
self.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_start,
|
HostService::register_start,
|
||||||
req,
|
req,
|
||||||
Msg::RegistrationStartResponse,
|
Msg::RegistrationStartResponse,
|
||||||
@@ -178,7 +165,7 @@ impl ChangePasswordForm {
|
|||||||
server_data: res.server_data,
|
server_data: res.server_data,
|
||||||
registration_upload: registration_finish.message,
|
registration_upload: registration_finish.message,
|
||||||
};
|
};
|
||||||
self.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_finish,
|
HostService::register_finish,
|
||||||
req,
|
req,
|
||||||
Msg::RegistrationFinishResponse,
|
Msg::RegistrationFinishResponse,
|
||||||
@@ -189,11 +176,11 @@ impl ChangePasswordForm {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
self.task = None;
|
self.common.cancel_task();
|
||||||
if response.is_ok() {
|
if response.is_ok() {
|
||||||
self.route_dispatcher
|
self.route_dispatcher
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(
|
.send(RouteRequest::ChangeRoute(Route::from(
|
||||||
AppRoute::UserDetails(self.props.username.clone()),
|
AppRoute::UserDetails(self.common.username.clone()),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
response?;
|
response?;
|
||||||
@@ -201,6 +188,10 @@ impl ChangePasswordForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for ChangePasswordForm {
|
impl Component for ChangePasswordForm {
|
||||||
@@ -209,27 +200,15 @@ impl Component for ChangePasswordForm {
|
|||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
ChangePasswordForm {
|
ChangePasswordForm {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
props,
|
|
||||||
error: None,
|
|
||||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||||
opaque_data: OpaqueData::None,
|
opaque_data: OpaqueData::None,
|
||||||
task: None,
|
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
route_dispatcher: RouteAgentDispatcher::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.error = None;
|
CommonComponentParts::<Self>::update(self, msg)
|
||||||
match self.handle_message(msg) {
|
|
||||||
Err(e) => {
|
|
||||||
ConsoleService::error(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
self.task = None;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -237,7 +216,7 @@ impl Component for ChangePasswordForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
fn view(&self) -> Html {
|
||||||
let is_admin = self.props.is_admin;
|
let is_admin = self.common.is_admin;
|
||||||
type Field = yew_form::Field<FormModel>;
|
type Field = yew_form::Field<FormModel>;
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
@@ -257,7 +236,7 @@ impl Component for ChangePasswordForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
oninput=self.link.callback(|_| Msg::FormUpdate) />
|
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("old_password")}
|
{&self.form.field_message("old_password")}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +256,7 @@ impl Component for ChangePasswordForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
oninput=self.link.callback(|_| Msg::FormUpdate) />
|
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("password")}
|
{&self.form.field_message("password")}
|
||||||
</div>
|
</div>
|
||||||
@@ -296,7 +275,7 @@ impl Component for ChangePasswordForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
oninput=self.link.callback(|_| Msg::FormUpdate) />
|
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("confirm_password")}
|
{&self.form.field_message("confirm_password")}
|
||||||
</div>
|
</div>
|
||||||
@@ -306,13 +285,13 @@ impl Component for ChangePasswordForm {
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary col-sm-1 col-form-label"
|
class="btn btn-primary col-sm-1 col-form-label"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled=self.task.is_some()
|
disabled=self.common.is_task_running()
|
||||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||||
{"Submit"}
|
{"Submit"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
{e.to_string() }
|
{e.to_string() }
|
||||||
@@ -323,7 +302,7 @@ impl Component for ChangePasswordForm {
|
|||||||
<div>
|
<div>
|
||||||
<NavButton
|
<NavButton
|
||||||
classes="btn btn-primary"
|
classes="btn btn-primary"
|
||||||
route=AppRoute::UserDetails(self.props.username.clone())>
|
route=AppRoute::UserDetails(self.common.username.clone())>
|
||||||
{"Back"}
|
{"Back"}
|
||||||
</NavButton>
|
</NavButton>
|
||||||
</div>
|
</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 anyhow::{bail, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
use yew::services::ConsoleService;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
agent::{RouteAgentDispatcher, RouteRequest},
|
||||||
@@ -20,12 +23,9 @@ use yew_router::{
|
|||||||
pub struct CreateGroup;
|
pub struct CreateGroup;
|
||||||
|
|
||||||
pub struct CreateGroupForm {
|
pub struct CreateGroupForm {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
route_dispatcher: RouteAgentDispatcher,
|
||||||
form: yew_form::Form<CreateGroupModel>,
|
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)]
|
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||||
@@ -40,7 +40,7 @@ pub enum Msg {
|
|||||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CreateGroupForm {
|
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
@@ -52,11 +52,11 @@ impl CreateGroupForm {
|
|||||||
let req = create_group::Variables {
|
let req = create_group::Variables {
|
||||||
name: model.groupname,
|
name: model.groupname,
|
||||||
};
|
};
|
||||||
self.task = Some(HostService::graphql_query::<CreateGroup>(
|
self.common.call_graphql::<CreateGroup, _>(
|
||||||
req,
|
req,
|
||||||
self.link.callback(Msg::CreateGroupResponse),
|
Msg::CreateGroupResponse,
|
||||||
"Error trying to create group",
|
"Error trying to create group",
|
||||||
)?);
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::CreateGroupResponse(response) => {
|
Msg::CreateGroupResponse(response) => {
|
||||||
@@ -70,33 +70,26 @@ impl CreateGroupForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for CreateGroupForm {
|
impl Component for CreateGroupForm {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
route_dispatcher: RouteAgentDispatcher::new(),
|
||||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||||
error: None,
|
|
||||||
task: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.error = None;
|
CommonComponentParts::<Self>::update(self, msg)
|
||||||
match self.handle_msg(msg) {
|
|
||||||
Err(e) => {
|
|
||||||
ConsoleService::error(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
self.task = None;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -124,7 +117,7 @@ impl Component for CreateGroupForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="groupname"
|
autocomplete="groupname"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("groupname")}
|
{&self.form.field_message("groupname")}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,13 +127,13 @@ impl Component for CreateGroupForm {
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary col-auto col-form-label"
|
class="btn btn-primary col-auto col-form-label"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled=self.task.is_some()
|
disabled=self.common.is_task_running()
|
||||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||||
{"Submit"}
|
{"Submit"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
{e.to_string() }
|
{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 anyhow::{bail, Context, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{opaque, registration};
|
use lldap_auth::{opaque, registration};
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
use yew::services::ConsoleService;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
agent::{RouteAgentDispatcher, RouteRequest},
|
||||||
@@ -21,12 +27,9 @@ use yew_router::{
|
|||||||
pub struct CreateUser;
|
pub struct CreateUser;
|
||||||
|
|
||||||
pub struct CreateUserForm {
|
pub struct CreateUserForm {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
route_dispatcher: RouteAgentDispatcher,
|
||||||
form: yew_form::Form<CreateUserModel>,
|
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)]
|
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||||
@@ -70,7 +73,7 @@ pub enum Msg {
|
|||||||
RegistrationFinishResponse(Result<()>),
|
RegistrationFinishResponse(Result<()>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CreateUserForm {
|
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
@@ -89,11 +92,11 @@ impl CreateUserForm {
|
|||||||
lastName: to_option(model.last_name),
|
lastName: to_option(model.last_name),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.task = Some(HostService::graphql_query::<CreateUser>(
|
self.common.call_graphql::<CreateUser, _>(
|
||||||
req,
|
req,
|
||||||
self.link.callback(Msg::CreateUserResponse),
|
Msg::CreateUserResponse,
|
||||||
"Error trying to create user",
|
"Error trying to create user",
|
||||||
)?);
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::CreateUserResponse(r) => {
|
Msg::CreateUserResponse(r) => {
|
||||||
@@ -118,14 +121,11 @@ impl CreateUserForm {
|
|||||||
username: user_id,
|
username: user_id,
|
||||||
registration_start_request: message,
|
registration_start_request: message,
|
||||||
};
|
};
|
||||||
self.task = Some(
|
self.common
|
||||||
HostService::register_start(
|
.call_backend(HostService::register_start, req, move |r| {
|
||||||
req,
|
Msg::RegistrationStartResponse((state, r))
|
||||||
self.link
|
})
|
||||||
.callback_once(move |r| Msg::RegistrationStartResponse((state, r))),
|
.context("Error trying to create user")?;
|
||||||
)
|
|
||||||
.context("Error trying to create user")?,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
self.update(Msg::SuccessfulCreation);
|
self.update(Msg::SuccessfulCreation);
|
||||||
}
|
}
|
||||||
@@ -143,13 +143,13 @@ impl CreateUserForm {
|
|||||||
server_data: response.server_data,
|
server_data: response.server_data,
|
||||||
registration_upload: registration_upload.message,
|
registration_upload: registration_upload.message,
|
||||||
};
|
};
|
||||||
self.task = Some(
|
self.common
|
||||||
HostService::register_finish(
|
.call_backend(
|
||||||
|
HostService::register_finish,
|
||||||
req,
|
req,
|
||||||
self.link.callback(Msg::RegistrationFinishResponse),
|
Msg::RegistrationFinishResponse,
|
||||||
)
|
)
|
||||||
.context("Error trying to register user")?,
|
.context("Error trying to register user")?;
|
||||||
);
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
@@ -163,33 +163,26 @@ impl CreateUserForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for CreateUserForm {
|
impl Component for CreateUserForm {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
route_dispatcher: RouteAgentDispatcher::new(),
|
||||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||||
error: None,
|
|
||||||
task: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.error = None;
|
CommonComponentParts::<Self>::update(self, msg)
|
||||||
match self.handle_msg(msg) {
|
|
||||||
Err(e) => {
|
|
||||||
ConsoleService::error(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
self.task = None;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -217,7 +210,7 @@ impl Component for CreateUserForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("username")}
|
{&self.form.field_message("username")}
|
||||||
</div>
|
</div>
|
||||||
@@ -237,7 +230,7 @@ impl Component for CreateUserForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("email")}
|
{&self.form.field_message("email")}
|
||||||
</div>
|
</div>
|
||||||
@@ -256,7 +249,7 @@ impl Component for CreateUserForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
field_name="display_name"
|
field_name="display_name"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("display_name")}
|
{&self.form.field_message("display_name")}
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +268,7 @@ impl Component for CreateUserForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
field_name="first_name"
|
field_name="first_name"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("first_name")}
|
{&self.form.field_message("first_name")}
|
||||||
</div>
|
</div>
|
||||||
@@ -294,7 +287,7 @@ impl Component for CreateUserForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
field_name="last_name"
|
field_name="last_name"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("last_name")}
|
{&self.form.field_message("last_name")}
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +307,7 @@ impl Component for CreateUserForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("password")}
|
{&self.form.field_message("password")}
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +327,7 @@ impl Component for CreateUserForm {
|
|||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("confirm_password")}
|
{&self.form.field_message("confirm_password")}
|
||||||
</div>
|
</div>
|
||||||
@@ -343,14 +336,14 @@ impl Component for CreateUserForm {
|
|||||||
<div class="form-group row justify-content-center">
|
<div class="form-group row justify-content-center">
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary col-auto col-form-label mt-4"
|
class="btn btn-primary col-auto col-form-label mt-4"
|
||||||
disabled=self.task.is_some()
|
disabled=self.common.is_task_running()
|
||||||
type="submit"
|
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"}
|
{"Submit"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
{e.to_string() }
|
{e.to_string() }
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::group_table::Group,
|
components::group_table::Group,
|
||||||
infra::{api::HostService, modal::Modal},
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
modal::Modal,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::fetch::FetchTask;
|
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -18,11 +19,9 @@ use yewtil::NeqAssign;
|
|||||||
pub struct DeleteGroupQuery;
|
pub struct DeleteGroupQuery;
|
||||||
|
|
||||||
pub struct DeleteGroup {
|
pub struct DeleteGroup {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
props: DeleteGroupProps,
|
|
||||||
node_ref: NodeRef,
|
node_ref: NodeRef,
|
||||||
modal: Option<Modal>,
|
modal: Option<Modal>,
|
||||||
task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||||
@@ -39,17 +38,51 @@ pub enum Msg {
|
|||||||
DeleteGroupResponse(Result<delete_group_query::ResponseData>),
|
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 {
|
impl Component for DeleteGroup {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = DeleteGroupProps;
|
type Properties = DeleteGroupProps;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
props,
|
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
modal: None,
|
modal: None,
|
||||||
task: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,39 +97,15 @@ impl Component for DeleteGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
match msg {
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
Msg::ClickedDeleteGroup => {
|
self,
|
||||||
self.modal.as_ref().expect("modal not initialized").show();
|
msg,
|
||||||
}
|
self.common.on_error.clone(),
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||||
self.props.neq_assign(props)
|
self.common.change(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
fn view(&self) -> Html {
|
||||||
@@ -104,8 +113,8 @@ impl Component for DeleteGroup {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.task.is_some()
|
disabled=self.common.is_task_running()
|
||||||
onclick=self.link.callback(|_| Msg::ClickedDeleteGroup)>
|
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
||||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||||
</button>
|
</button>
|
||||||
{self.show_modal()}
|
{self.show_modal()}
|
||||||
@@ -119,7 +128,7 @@ impl DeleteGroup {
|
|||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
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"
|
tabindex="-1"
|
||||||
aria-labelledby="deleteGroupModalLabel"
|
aria-labelledby="deleteGroupModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -132,24 +141,24 @@ impl DeleteGroup {
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onclick=self.link.callback(|_| Msg::DismissModal) />
|
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span>
|
<span>
|
||||||
{"Are you sure you want to delete group "}
|
{"Are you sure you want to delete group "}
|
||||||
<b>{&self.props.group.display_name}</b>{"?"}
|
<b>{&self.common.group.display_name}</b>{"?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
onclick=self.link.callback(|_| Msg::DismissModal)>
|
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||||
{"Cancel"}
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick=self.link.callback(|_| Msg::ConfirmDeleteGroup)
|
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
||||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||||
</div>
|
</div>
|
||||||
</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 anyhow::{Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::fetch::FetchTask;
|
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -15,11 +16,9 @@ use yewtil::NeqAssign;
|
|||||||
pub struct DeleteUserQuery;
|
pub struct DeleteUserQuery;
|
||||||
|
|
||||||
pub struct DeleteUser {
|
pub struct DeleteUser {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
props: DeleteUserProps,
|
|
||||||
node_ref: NodeRef,
|
node_ref: NodeRef,
|
||||||
modal: Option<Modal>,
|
modal: Option<Modal>,
|
||||||
task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||||
@@ -36,17 +35,51 @@ pub enum Msg {
|
|||||||
DeleteUserResponse(Result<delete_user_query::ResponseData>),
|
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 {
|
impl Component for DeleteUser {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = DeleteUserProps;
|
type Properties = DeleteUserProps;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
props,
|
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
modal: None,
|
modal: None,
|
||||||
task: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,39 +94,15 @@ impl Component for DeleteUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
match msg {
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
Msg::ClickedDeleteUser => {
|
self,
|
||||||
self.modal.as_ref().expect("modal not initialized").show();
|
msg,
|
||||||
}
|
self.common.on_error.clone(),
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||||
self.props.neq_assign(props)
|
self.common.change(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
fn view(&self) -> Html {
|
||||||
@@ -101,8 +110,8 @@ impl Component for DeleteUser {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.task.is_some()
|
disabled=self.common.is_task_running()
|
||||||
onclick=self.link.callback(|_| Msg::ClickedDeleteUser)>
|
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
||||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||||
</button>
|
</button>
|
||||||
{self.show_modal()}
|
{self.show_modal()}
|
||||||
@@ -116,7 +125,7 @@ impl DeleteUser {
|
|||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
class="modal fade"
|
||||||
id="deleteUserModal".to_string() + &self.props.username
|
id="deleteUserModal".to_string() + &self.common.username
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
//role="dialog"
|
//role="dialog"
|
||||||
aria-labelledby="deleteUserModalLabel"
|
aria-labelledby="deleteUserModalLabel"
|
||||||
@@ -130,24 +139,24 @@ impl DeleteUser {
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onclick=self.link.callback(|_| Msg::DismissModal) />
|
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span>
|
<span>
|
||||||
{"Are you sure you want to delete user "}
|
{"Are you sure you want to delete user "}
|
||||||
<b>{&self.props.username}</b>{"?"}
|
<b>{&self.common.username}</b>{"?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
onclick=self.link.callback(|_| Msg::DismissModal)>
|
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||||
{"Cancel"}
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick=self.link.callback(|_| Msg::ConfirmDeleteUser)
|
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
||||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ use crate::{
|
|||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
infra::api::HostService,
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::{
|
use yew::prelude::*;
|
||||||
prelude::*,
|
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -27,15 +24,10 @@ pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
|||||||
pub type AddGroupMemberUser = add_group_member::User;
|
pub type AddGroupMemberUser = add_group_member::User;
|
||||||
|
|
||||||
pub struct GroupDetails {
|
pub struct GroupDetails {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
props: Props,
|
|
||||||
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||||
/// received the server response yet.
|
/// received the server response yet.
|
||||||
group: Option<Group>,
|
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.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -55,45 +47,13 @@ pub struct Props {
|
|||||||
|
|
||||||
impl GroupDetails {
|
impl GroupDetails {
|
||||||
fn get_group_details(&mut self) {
|
fn get_group_details(&mut self) {
|
||||||
self._task = HostService::graphql_query::<GetGroupDetails>(
|
self.common.call_graphql::<GetGroupDetails, _>(
|
||||||
get_group_details::Variables {
|
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",
|
"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 {
|
fn view_messages(&self, error: &Option<Error>) -> Html {
|
||||||
@@ -124,8 +84,8 @@ impl GroupDetails {
|
|||||||
<RemoveUserFromGroupComponent
|
<RemoveUserFromGroupComponent
|
||||||
username=user_id
|
username=user_id
|
||||||
group_id=g.id
|
group_id=g.id
|
||||||
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
|
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||||
on_error=self.link.callback(Msg::OnError)/>
|
on_error=self.common.callback(Msg::OnError)/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -174,38 +134,60 @@ impl GroupDetails {
|
|||||||
<AddGroupMemberComponent
|
<AddGroupMemberComponent
|
||||||
group_id=g.id
|
group_id=g.id
|
||||||
users=users
|
users=users
|
||||||
on_error=self.link.callback(Msg::OnError)
|
on_error=self.common.callback(Msg::OnError)
|
||||||
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/>
|
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 {
|
impl Component for GroupDetails {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
props,
|
|
||||||
_task: None,
|
|
||||||
group: None,
|
group: None,
|
||||||
error: None,
|
|
||||||
};
|
};
|
||||||
table.get_group_details();
|
table.get_group_details();
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.error = None;
|
CommonComponentParts::<Self>::update(self, msg)
|
||||||
match self.handle_msg(msg) {
|
|
||||||
Err(e) => {
|
|
||||||
ConsoleService::error(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -213,7 +195,7 @@ impl Component for GroupDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
fn view(&self) -> Html {
|
||||||
match (&self.group, &self.error) {
|
match (&self.group, &self.common.error) {
|
||||||
(None, None) => html! {{"Loading..."}},
|
(None, None) => html! {{"Loading..."}},
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
(Some(u), error) => {
|
(Some(u), error) => {
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ use crate::{
|
|||||||
delete_group::DeleteGroup,
|
delete_group::DeleteGroup,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
infra::api::HostService,
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -24,11 +23,8 @@ use get_group_list::ResponseData;
|
|||||||
pub type Group = get_group_list::GetGroupListGroups;
|
pub type Group = get_group_list::GetGroupListGroups;
|
||||||
|
|
||||||
pub struct GroupTable {
|
pub struct GroupTable {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
groups: Option<Vec<Group>>,
|
groups: Option<Vec<Group>>,
|
||||||
error: Option<Error>,
|
|
||||||
// Used to keep the request alive long enough.
|
|
||||||
_task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
@@ -37,18 +33,24 @@ pub enum Msg {
|
|||||||
OnError(Error),
|
OnError(Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupTable {
|
impl CommonComponent<GroupTable> for GroupTable {
|
||||||
fn get_groups(&mut self) {
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
self._task = HostService::graphql_query::<GetGroupList>(
|
match msg {
|
||||||
get_group_list::Variables {},
|
Msg::ListGroupsResponse(groups) => {
|
||||||
self.link.callback(Msg::ListGroupsResponse),
|
self.groups = Some(groups?.groups.into_iter().collect());
|
||||||
"Error trying to fetch groups",
|
Ok(true)
|
||||||
)
|
}
|
||||||
.map_err(|e| {
|
Msg::OnError(e) => Err(e),
|
||||||
ConsoleService::log(&e.to_string());
|
Msg::OnGroupDeleted(group_id) => {
|
||||||
e
|
debug_assert!(self.groups.is_some());
|
||||||
})
|
self.groups.as_mut().unwrap().retain(|u| u.id != group_id);
|
||||||
.ok();
|
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 Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
let mut table = GroupTable {
|
let mut table = GroupTable {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
_task: None,
|
|
||||||
groups: None,
|
groups: None,
|
||||||
error: None,
|
|
||||||
};
|
};
|
||||||
table.get_groups();
|
table.common.call_graphql::<GetGroupList, _>(
|
||||||
|
get_group_list::Variables {},
|
||||||
|
Msg::ListGroupsResponse,
|
||||||
|
"Error trying to fetch groups",
|
||||||
|
);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.error = None;
|
CommonComponentParts::<Self>::update(self, msg)
|
||||||
match self.handle_msg(msg) {
|
|
||||||
Err(e) => {
|
|
||||||
ConsoleService::error(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -94,21 +90,6 @@ impl Component for GroupTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn view_groups(&self) -> Html {
|
||||||
let make_table = |groups: &Vec<Group>| {
|
let make_table = |groups: &Vec<Group>| {
|
||||||
html! {
|
html! {
|
||||||
@@ -144,15 +125,15 @@ impl GroupTable {
|
|||||||
<td>
|
<td>
|
||||||
<DeleteGroup
|
<DeleteGroup
|
||||||
group=group.clone()
|
group=group.clone()
|
||||||
on_group_deleted=self.link.callback(Msg::OnGroupDeleted)
|
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
||||||
on_error=self.link.callback(Msg::OnError)/>
|
on_error=self.common.callback(Msg::OnError)/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_errors(&self) -> Html {
|
fn view_errors(&self) -> Html {
|
||||||
match &self.error {
|
match &self.common.error {
|
||||||
None => html! {},
|
None => html! {},
|
||||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
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 anyhow::{anyhow, bail, Context, Result};
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::{
|
use yew::{prelude::*, services::ConsoleService};
|
||||||
prelude::*,
|
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
|
||||||
};
|
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
pub struct LoginForm {
|
pub struct LoginForm {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
on_logged_in: Callback<(String, bool)>,
|
|
||||||
error: Option<anyhow::Error>,
|
|
||||||
form: Form<FormModel>,
|
form: Form<FormModel>,
|
||||||
// Used to keep the request alive long enough.
|
|
||||||
task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fields of the form, with the constraints.
|
/// The fields of the form, with the constraints.
|
||||||
@@ -44,8 +43,8 @@ pub enum Msg {
|
|||||||
AuthenticationFinishResponse(Result<(String, bool)>),
|
AuthenticationFinishResponse(Result<(String, bool)>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoginForm {
|
impl CommonComponent<LoginForm> for LoginForm {
|
||||||
fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
@@ -61,11 +60,10 @@ impl LoginForm {
|
|||||||
username,
|
username,
|
||||||
login_start_request: message,
|
login_start_request: message,
|
||||||
};
|
};
|
||||||
self.task = Some(HostService::login_start(
|
self.common
|
||||||
req,
|
.call_backend(HostService::login_start, req, move |r| {
|
||||||
self.link
|
Msg::AuthenticationStartResponse((state, r))
|
||||||
.callback_once(move |r| Msg::AuthenticationStartResponse((state, r))),
|
})?;
|
||||||
)?);
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
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
|
// Common error, we want to print a full error to the console but only a
|
||||||
// simple one to the user.
|
// simple one to the user.
|
||||||
ConsoleService::error(&format!("Invalid username or password: {}", e));
|
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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
@@ -86,20 +85,26 @@ impl LoginForm {
|
|||||||
server_data: res.server_data,
|
server_data: res.server_data,
|
||||||
credential_finalization: login_finish.message,
|
credential_finalization: login_finish.message,
|
||||||
};
|
};
|
||||||
self.task = Some(HostService::login_finish(
|
self.common.call_backend(
|
||||||
|
HostService::login_finish,
|
||||||
req,
|
req,
|
||||||
self.link.callback_once(Msg::AuthenticationFinishResponse),
|
Msg::AuthenticationFinishResponse,
|
||||||
)?);
|
)?;
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationFinishResponse(user_info) => {
|
Msg::AuthenticationFinishResponse(user_info) => {
|
||||||
self.task = None;
|
self.common.cancel_task();
|
||||||
self.on_logged_in
|
self.common
|
||||||
|
.on_logged_in
|
||||||
.emit(user_info.context("Could not log in")?);
|
.emit(user_info.context("Could not log in")?);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for LoginForm {
|
impl Component for LoginForm {
|
||||||
@@ -108,25 +113,13 @@ impl Component for LoginForm {
|
|||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
LoginForm {
|
LoginForm {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
on_logged_in: props.on_logged_in,
|
|
||||||
error: None,
|
|
||||||
form: Form::<FormModel>::new(FormModel::default()),
|
form: Form::<FormModel>::new(FormModel::default()),
|
||||||
task: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.error = None;
|
CommonComponentParts::<Self>::update(self, msg)
|
||||||
match self.handle_message(msg) {
|
|
||||||
Err(e) => {
|
|
||||||
ConsoleService::error(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
self.task = None;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -152,7 +145,7 @@ impl Component for LoginForm {
|
|||||||
field_name="username"
|
field_name="username"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="input-group-prepend">
|
<div class="input-group-prepend">
|
||||||
@@ -170,17 +163,23 @@ impl Component for LoginForm {
|
|||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
autocomplete="current-password" />
|
autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group mt-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
disabled=self.task.is_some()
|
disabled=self.common.is_task_running()
|
||||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||||
{"Login"}
|
{"Login"}
|
||||||
</button>
|
</button>
|
||||||
|
<NavButton
|
||||||
|
classes="btn-link btn"
|
||||||
|
disabled=self.common.is_task_running()
|
||||||
|
route=AppRoute::StartResetPassword>
|
||||||
|
{"Forgot your password?"}
|
||||||
|
</NavButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{ if let Some(e) = &self.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! { e.to_string() }
|
html! { e.to_string() }
|
||||||
} else { html! {} }
|
} 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 anyhow::Result;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
|
||||||
|
|
||||||
pub struct LogoutButton {
|
pub struct LogoutButton {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
on_logged_out: Callback<()>,
|
|
||||||
// Used to keep the request alive long enough.
|
|
||||||
_task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Properties)]
|
#[derive(Clone, PartialEq, Properties)]
|
||||||
@@ -20,43 +20,39 @@ pub enum Msg {
|
|||||||
LogoutCompleted(Result<()>),
|
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 {
|
impl Component for LogoutButton {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
LogoutButton {
|
LogoutButton {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
on_logged_out: props.on_logged_out,
|
|
||||||
_task: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
match msg {
|
CommonComponentParts::<Self>::update(self, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -67,7 +63,7 @@ impl Component for LogoutButton {
|
|||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
onclick=self.link.callback(|_| Msg::LogoutRequested)>
|
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
||||||
{"Logout"}
|
{"Logout"}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ pub mod group_table;
|
|||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
pub mod remove_user_from_group;
|
pub mod remove_user_from_group;
|
||||||
|
pub mod reset_password_step1;
|
||||||
|
pub mod reset_password_step2;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod user_details;
|
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 anyhow::{Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::{
|
use yew::prelude::*;
|
||||||
prelude::*,
|
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -17,10 +14,7 @@ use yew::{
|
|||||||
pub struct RemoveUserFromGroup;
|
pub struct RemoveUserFromGroup;
|
||||||
|
|
||||||
pub struct RemoveUserFromGroupComponent {
|
pub struct RemoveUserFromGroupComponent {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
props: Props,
|
|
||||||
// Used to keep the request alive long enough.
|
|
||||||
task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq)]
|
#[derive(yew::Properties, Clone, PartialEq)]
|
||||||
@@ -36,38 +30,37 @@ pub enum Msg {
|
|||||||
RemoveGroupResponse(Result<remove_user_from_group::ResponseData>),
|
RemoveGroupResponse(Result<remove_user_from_group::ResponseData>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RemoveUserFromGroupComponent {
|
impl CommonComponent<RemoveUserFromGroupComponent> for 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::SubmitRemoveGroup => return self.submit_remove_group(),
|
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
||||||
Msg::RemoveGroupResponse(response) => {
|
Msg::RemoveGroupResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.task = None;
|
self.common.cancel_task();
|
||||||
self.props
|
self.common
|
||||||
.on_user_removed_from_group
|
.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)
|
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 {
|
impl Component for RemoveUserFromGroupComponent {
|
||||||
@@ -76,21 +69,16 @@ impl Component for RemoveUserFromGroupComponent {
|
|||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
props,
|
|
||||||
task: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
match self.handle_msg(msg) {
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
Err(e) => {
|
self,
|
||||||
self.task = None;
|
msg,
|
||||||
self.props.on_error.emit(e);
|
self.common.on_error.clone(),
|
||||||
true
|
)
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -101,8 +89,8 @@ impl Component for RemoveUserFromGroupComponent {
|
|||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.task.is_some()
|
disabled=self.common.is_task_running()
|
||||||
onclick=self.link.callback(|_| Msg::SubmitRemoveGroup)>
|
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
||||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
pub enum AppRoute {
|
||||||
#[to = "/login"]
|
#[to = "/login"]
|
||||||
Login,
|
Login,
|
||||||
|
#[to = "/reset-password/step1"]
|
||||||
|
StartResetPassword,
|
||||||
|
#[to = "/reset-password/step2/{token}"]
|
||||||
|
FinishResetPassword(String),
|
||||||
#[to = "/users/create"]
|
#[to = "/users/create"]
|
||||||
CreateUser,
|
CreateUser,
|
||||||
#[to = "/users"]
|
#[to = "/users"]
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ use crate::{
|
|||||||
router::{AppRoute, Link, NavButton},
|
router::{AppRoute, Link, NavButton},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
},
|
},
|
||||||
infra::api::HostService,
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::{
|
use yew::prelude::*;
|
||||||
prelude::*,
|
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -27,15 +24,10 @@ pub type User = get_user_details::GetUserDetailsUser;
|
|||||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||||
|
|
||||||
pub struct UserDetails {
|
pub struct UserDetails {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
props: Props,
|
|
||||||
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||||
/// received the server response yet.
|
/// received the server response yet.
|
||||||
user: Option<User>,
|
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.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -54,22 +46,7 @@ pub struct Props {
|
|||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserDetails {
|
impl CommonComponent<UserDetails> for 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserDetailsResponse(response) => match response {
|
Msg::UserDetailsResponse(response) => match response {
|
||||||
@@ -94,6 +71,22 @@ impl UserDetails {
|
|||||||
Ok(true)
|
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 {
|
fn view_messages(&self, error: &Option<Error>) -> Html {
|
||||||
if let Some(e) = error {
|
if let Some(e) = error {
|
||||||
html! {
|
html! {
|
||||||
@@ -111,7 +104,7 @@ impl UserDetails {
|
|||||||
let display_name = group.display_name.clone();
|
let display_name = group.display_name.clone();
|
||||||
html! {
|
html! {
|
||||||
<tr key="groupRow_".to_string() + &display_name>
|
<tr key="groupRow_".to_string() + &display_name>
|
||||||
{if self.props.is_admin { html! {
|
{if self.common.is_admin { html! {
|
||||||
<>
|
<>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::GroupDetails(group.id)>
|
<Link route=AppRoute::GroupDetails(group.id)>
|
||||||
@@ -122,8 +115,8 @@ impl UserDetails {
|
|||||||
<RemoveUserFromGroupComponent
|
<RemoveUserFromGroupComponent
|
||||||
username=u.id.clone()
|
username=u.id.clone()
|
||||||
group_id=group.id
|
group_id=group.id
|
||||||
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
|
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||||
on_error=self.link.callback(Msg::OnError)/>
|
on_error=self.common.callback(Msg::OnError)/>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
} } else { html! {
|
} } else { html! {
|
||||||
@@ -140,7 +133,7 @@ impl UserDetails {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr key="headerRow">
|
<tr key="headerRow">
|
||||||
<th>{"Group"}</th>
|
<th>{"Group"}</th>
|
||||||
{ if self.props.is_admin { html!{ <th></th> }} else { html!{} }}
|
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -161,13 +154,13 @@ impl UserDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view_add_group_button(&self, u: &User) -> Html {
|
fn view_add_group_button(&self, u: &User) -> Html {
|
||||||
if self.props.is_admin {
|
if self.common.is_admin {
|
||||||
html! {
|
html! {
|
||||||
<AddUserToGroupComponent
|
<AddUserToGroupComponent
|
||||||
username=u.id.clone()
|
username=u.id.clone()
|
||||||
groups=u.groups.clone()
|
groups=u.groups.clone()
|
||||||
on_error=self.link.callback(Msg::OnError)
|
on_error=self.common.callback(Msg::OnError)
|
||||||
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/>
|
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
@@ -181,26 +174,15 @@ impl Component for UserDetails {
|
|||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
props,
|
|
||||||
_task: None,
|
|
||||||
user: None,
|
user: None,
|
||||||
error: None,
|
|
||||||
};
|
};
|
||||||
table.get_user_details();
|
table.get_user_details();
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.error = None;
|
CommonComponentParts::<Self>::update(self, msg)
|
||||||
match self.handle_msg(msg) {
|
|
||||||
Err(e) => {
|
|
||||||
ConsoleService::error(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -208,7 +190,7 @@ impl Component for UserDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
fn view(&self) -> Html {
|
||||||
match (&self.user, &self.error) {
|
match (&self.user, &self.common.error) {
|
||||||
(None, None) => html! {{"Loading..."}},
|
(None, None) => html! {{"Loading..."}},
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
(Some(u), error) => {
|
(Some(u), error) => {
|
||||||
@@ -217,7 +199,7 @@ impl Component for UserDetails {
|
|||||||
<h3>{u.id.to_string()}</h3>
|
<h3>{u.id.to_string()}</h3>
|
||||||
<UserDetailsForm
|
<UserDetailsForm
|
||||||
user=u.clone()
|
user=u.clone()
|
||||||
on_error=self.link.callback(Msg::OnError)/>
|
on_error=self.common.callback(Msg::OnError)/>
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<NavButton
|
<NavButton
|
||||||
route=AppRoute::ChangePassword(u.id.clone())
|
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 anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::{
|
use yew::prelude::*;
|
||||||
prelude::*,
|
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
|
||||||
};
|
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
/// The fields of the form, with the editable details and the constraints.
|
/// 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.
|
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||||
pub struct UserDetailsForm {
|
pub struct UserDetailsForm {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
props: Props,
|
|
||||||
form: yew_form::Form<UserModel>,
|
form: yew_form::Form<UserModel>,
|
||||||
/// True if we just successfully updated the user, to display a success message.
|
/// True if we just successfully updated the user, to display a success message.
|
||||||
just_updated: bool,
|
just_updated: bool,
|
||||||
task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
@@ -57,6 +55,20 @@ pub struct Props {
|
|||||||
pub on_error: Callback<Error>,
|
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 {
|
impl Component for UserDetailsForm {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
@@ -69,25 +81,19 @@ impl Component for UserDetailsForm {
|
|||||||
last_name: props.user.last_name.clone(),
|
last_name: props.user.last_name.clone(),
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
form: yew_form::Form::new(model),
|
form: yew_form::Form::new(model),
|
||||||
props,
|
|
||||||
just_updated: false,
|
just_updated: false,
|
||||||
task: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.just_updated = false;
|
self.just_updated = false;
|
||||||
match self.handle_msg(msg) {
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
Err(e) => {
|
self,
|
||||||
ConsoleService::error(&e.to_string());
|
msg,
|
||||||
self.props.on_error.emit(e);
|
self.common.on_error.clone(),
|
||||||
self.task = None;
|
)
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -105,7 +111,7 @@ impl Component for UserDetailsForm {
|
|||||||
{"User ID: "}
|
{"User ID: "}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<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>
|
</div>
|
||||||
<div class="form-group row mb-3">
|
<div class="form-group row mb-3">
|
||||||
@@ -121,7 +127,7 @@ impl Component for UserDetailsForm {
|
|||||||
form=&self.form
|
form=&self.form
|
||||||
field_name="email"
|
field_name="email"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("email")}
|
{&self.form.field_message("email")}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +146,7 @@ impl Component for UserDetailsForm {
|
|||||||
form=&self.form
|
form=&self.form
|
||||||
field_name="display_name"
|
field_name="display_name"
|
||||||
autocomplete="name"
|
autocomplete="name"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("display_name")}
|
{&self.form.field_message("display_name")}
|
||||||
</div>
|
</div>
|
||||||
@@ -157,7 +163,7 @@ impl Component for UserDetailsForm {
|
|||||||
form=&self.form
|
form=&self.form
|
||||||
field_name="first_name"
|
field_name="first_name"
|
||||||
autocomplete="given-name"
|
autocomplete="given-name"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("first_name")}
|
{&self.form.field_message("first_name")}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +180,7 @@ impl Component for UserDetailsForm {
|
|||||||
form=&self.form
|
form=&self.form
|
||||||
field_name="last_name"
|
field_name="last_name"
|
||||||
autocomplete="family-name"
|
autocomplete="family-name"
|
||||||
oninput=self.link.callback(|_| Msg::Update) />
|
oninput=self.common.callback(|_| Msg::Update) />
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
{&self.form.field_message("last_name")}
|
{&self.form.field_message("last_name")}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,15 +192,15 @@ impl Component for UserDetailsForm {
|
|||||||
{"Creation date: "}
|
{"Creation date: "}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<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>
|
</div>
|
||||||
<div class="form-group row justify-content-center">
|
<div class="form-group row justify-content-center">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary col-auto col-form-label"
|
class="btn btn-primary col-auto col-form-label"
|
||||||
disabled=self.task.is_some()
|
disabled=self.common.is_task_running()
|
||||||
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
||||||
{"Update"}
|
{"Update"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,21 +214,13 @@ impl Component for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
fn submit_user_update_form(&mut self) -> Result<bool> {
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
bail!("Invalid inputs");
|
bail!("Invalid inputs");
|
||||||
}
|
}
|
||||||
let base_user = &self.props.user;
|
let base_user = &self.common.user;
|
||||||
let mut user_input = update_user::UpdateUserInput {
|
let mut user_input = update_user::UpdateUserInput {
|
||||||
id: self.props.user.id.clone(),
|
id: self.common.user.id.clone(),
|
||||||
email: None,
|
email: None,
|
||||||
displayName: None,
|
displayName: None,
|
||||||
firstName: None,
|
firstName: None,
|
||||||
@@ -248,28 +246,28 @@ impl UserDetailsForm {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let req = update_user::Variables { user: user_input };
|
let req = update_user::Variables { user: user_input };
|
||||||
self.task = Some(HostService::graphql_query::<UpdateUser>(
|
self.common.call_graphql::<UpdateUser, _>(
|
||||||
req,
|
req,
|
||||||
self.link.callback(Msg::UserUpdated),
|
Msg::UserUpdated,
|
||||||
"Error trying to update user",
|
"Error trying to update user",
|
||||||
)?);
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||||
self.task = None;
|
self.common.cancel_task();
|
||||||
match r {
|
match r {
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
self.props.user = User {
|
self.common.user = User {
|
||||||
id: self.props.user.id.clone(),
|
id: self.common.user.id.clone(),
|
||||||
email: model.email,
|
email: model.email,
|
||||||
display_name: model.display_name,
|
display_name: model.display_name,
|
||||||
first_name: model.first_name,
|
first_name: model.first_name,
|
||||||
last_name: model.last_name,
|
last_name: model.last_name,
|
||||||
creation_date: self.props.user.creation_date,
|
creation_date: self.common.user.creation_date,
|
||||||
groups: self.props.user.groups.clone(),
|
groups: self.common.user.groups.clone(),
|
||||||
};
|
};
|
||||||
self.just_updated = true;
|
self.just_updated = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ use crate::{
|
|||||||
delete_user::DeleteUser,
|
delete_user::DeleteUser,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
infra::api::HostService,
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::{fetch::FetchTask, ConsoleService};
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -24,11 +23,8 @@ use list_users_query::{RequestFilter, ResponseData};
|
|||||||
type User = list_users_query::ListUsersQueryUsers;
|
type User = list_users_query::ListUsersQueryUsers;
|
||||||
|
|
||||||
pub struct UserTable {
|
pub struct UserTable {
|
||||||
link: ComponentLink<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
users: Option<Vec<User>>,
|
users: Option<Vec<User>>,
|
||||||
error: Option<Error>,
|
|
||||||
// Used to keep the request alive long enough.
|
|
||||||
_task: Option<FetchTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
@@ -37,18 +33,34 @@ pub enum Msg {
|
|||||||
OnError(Error),
|
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 {
|
impl UserTable {
|
||||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
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 },
|
list_users_query::Variables { filters: req },
|
||||||
self.link.callback(Msg::ListUsersResponse),
|
Msg::ListUsersResponse,
|
||||||
"Error trying to fetch users",
|
"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 Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||||
let mut table = UserTable {
|
let mut table = UserTable {
|
||||||
link,
|
common: CommonComponentParts::<Self>::create(props, link),
|
||||||
_task: None,
|
|
||||||
users: None,
|
users: None,
|
||||||
error: None,
|
|
||||||
};
|
};
|
||||||
table.get_users(None);
|
table.get_users(None);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||||
self.error = None;
|
CommonComponentParts::<Self>::update(self, msg)
|
||||||
match self.handle_msg(msg) {
|
|
||||||
Err(e) => {
|
|
||||||
ConsoleService::error(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||||
@@ -94,21 +96,6 @@ impl Component for UserTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn view_users(&self) -> Html {
|
||||||
let make_table = |users: &Vec<User>| {
|
let make_table = |users: &Vec<User>| {
|
||||||
html! {
|
html! {
|
||||||
@@ -150,15 +137,15 @@ impl UserTable {
|
|||||||
<td>
|
<td>
|
||||||
<DeleteUser
|
<DeleteUser
|
||||||
username=user.id.clone()
|
username=user.id.clone()
|
||||||
on_user_deleted=self.link.callback(Msg::OnUserDeleted)
|
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
||||||
on_error=self.link.callback(Msg::OnError)/>
|
on_error=self.common.callback(Msg::OnError)/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_errors(&self) -> Html {
|
fn view_errors(&self) -> Html {
|
||||||
match &self.error {
|
match &self.common.error {
|
||||||
None => html! {},
|
None => html! {},
|
||||||
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
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(
|
call_server_empty_response_with_error_message(
|
||||||
"/auth/logout",
|
"/auth/logout",
|
||||||
yew::format::Nothing,
|
yew::format::Nothing,
|
||||||
@@ -231,4 +232,28 @@ impl HostService {
|
|||||||
"Could not logout",
|
"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 api;
|
||||||
|
pub mod common_component;
|
||||||
pub mod cookies;
|
pub mod cookies;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
|
#![forbid(non_ascii_idents)]
|
||||||
#![allow(clippy::nonstandard_macro_braces)]
|
#![allow(clippy::nonstandard_macro_braces)]
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod infra;
|
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]
|
[package]
|
||||||
name = "lldap_auth"
|
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>"]
|
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||||
edition = "2018"
|
edition = "2021"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["opaque_server", "opaque_client"]
|
default = ["opaque_server", "opaque_client"]
|
||||||
@@ -20,30 +20,13 @@ serde = "*"
|
|||||||
sha2 = "0.9"
|
sha2 = "0.9"
|
||||||
thiserror = "*"
|
thiserror = "*"
|
||||||
|
|
||||||
# TODO: update to 0.6 when out.
|
|
||||||
[dependencies.opaque-ke]
|
[dependencies.opaque-ke]
|
||||||
git = "https://github.com/novifinancial/opaque-ke"
|
version = "0.6"
|
||||||
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
|
|
||||||
|
|
||||||
[dependencies.chrono]
|
[dependencies.chrono]
|
||||||
version = "*"
|
version = "*"
|
||||||
features = [ "serde" ]
|
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.
|
# For WASM targets, use the JS getrandom.
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||||
version = "0.2"
|
version = "0.2"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#![forbid(non_ascii_idents)]
|
||||||
#![allow(clippy::nonstandard_macro_braces)]
|
#![allow(clippy::nonstandard_macro_braces)]
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
## Default configuration for Docker.
|
||||||
## All the values can be overridden through environment variables. For
|
## All the values can be overridden through environment variables, prefixed
|
||||||
## instance, "ldap_port" can be overridden with the "LDAP_PORT" variable.
|
## with "LLDAP_". For instance, "ldap_port" can be overridden with the
|
||||||
|
## "LLDAP_LDAP_PORT" variable.
|
||||||
|
|
||||||
## The port on which to have the LDAP server.
|
## The port on which to have the LDAP server.
|
||||||
#ldap_port = 3890
|
#ldap_port = 3890
|
||||||
@@ -9,13 +10,18 @@
|
|||||||
## administration.
|
## administration.
|
||||||
#http_port = 17170
|
#http_port = 17170
|
||||||
|
|
||||||
|
## The public URL of the server, for password reset links.
|
||||||
|
#http_url = "http://localhost"
|
||||||
|
|
||||||
## Random secret for JWT signature.
|
## Random secret for JWT signature.
|
||||||
## This secret should be random, and should be shared with application
|
## This secret should be random, and should be shared with application
|
||||||
## servers that need to consume the JWTs.
|
## servers that need to consume the JWTs.
|
||||||
## Changing this secret will invalidate all user sessions and require
|
## Changing this secret will invalidate all user sessions and require
|
||||||
## them to re-login.
|
## 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.
|
## 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):
|
## You can generate it with (on linux):
|
||||||
## LC_ALL=C tr -dc 'A-Za-z0-9!"#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
|
## LC_ALL=C tr -dc 'A-Za-z0-9!"#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
|
||||||
#jwt_secret = "REPLACE_WITH_RANDOM"
|
#jwt_secret = "REPLACE_WITH_RANDOM"
|
||||||
@@ -31,16 +37,19 @@
|
|||||||
|
|
||||||
## Admin username.
|
## Admin username.
|
||||||
## For the LDAP interface, a value of "admin" here will create the LDAP
|
## 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.
|
## For the administration interface, this is the username.
|
||||||
#ldap_user_dn = "admin"
|
#ldap_user_dn = "admin"
|
||||||
|
|
||||||
## Admin password.
|
## Admin password.
|
||||||
## Password for the admin account, both for the LDAP bind and for the
|
## 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.
|
## It should be minimum 8 characters long.
|
||||||
## You can set it with the LDAP_USER_PASS environment variable.
|
## You can set it with the LLDAP_LDAP_USER_PASS environment variable.
|
||||||
## Note: you can create another admin user for LDAP/administration, this
|
## 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.
|
## is just the default one.
|
||||||
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
|
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
|
||||||
|
|
||||||
@@ -64,3 +73,25 @@ database_url = "sqlite:///data/users.db?mode=rwc"
|
|||||||
## each password.
|
## each password.
|
||||||
## Randomly generated on first run if it doesn't exist.
|
## Randomly generated on first run if it doesn't exist.
|
||||||
key_file = "/data/private_key"
|
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]
|
[package]
|
||||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||||
edition = "2018"
|
edition = "2021"
|
||||||
name = "lldap"
|
name = "lldap"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix = "0.12"
|
actix = "0.12"
|
||||||
@@ -26,7 +26,7 @@ futures-util = "*"
|
|||||||
hmac = "0.10"
|
hmac = "0.10"
|
||||||
http = "*"
|
http = "*"
|
||||||
jwt = "0.13"
|
jwt = "0.13"
|
||||||
ldap3_server = "*"
|
ldap3_server = ">=0.1.9"
|
||||||
lldap_auth = { path = "../auth" }
|
lldap_auth = { path = "../auth" }
|
||||||
log = "*"
|
log = "*"
|
||||||
orion = "0.16"
|
orion = "0.16"
|
||||||
@@ -38,19 +38,28 @@ thiserror = "*"
|
|||||||
time = "0.2"
|
time = "0.2"
|
||||||
tokio = { version = "1.2.0", features = ["full"] }
|
tokio = { version = "1.2.0", features = ["full"] }
|
||||||
tokio-util = "0.6.3"
|
tokio-util = "0.6.3"
|
||||||
|
tokio-stream = "*"
|
||||||
tracing = "*"
|
tracing = "*"
|
||||||
tracing-actix-web = "0.4.0-beta.7"
|
tracing-actix-web = "0.4.0-beta.7"
|
||||||
tracing-log = "*"
|
tracing-log = "*"
|
||||||
tracing-subscriber = "*"
|
tracing-subscriber = "0.3"
|
||||||
rand = { version = "0.8", features = ["small_rng", "getrandom"] }
|
rand = { version = "0.8", features = ["small_rng", "getrandom"] }
|
||||||
juniper_actix = "0.4.0"
|
juniper_actix = "0.4.0"
|
||||||
juniper = "0.15.6"
|
juniper = "0.15.6"
|
||||||
itertools = "0.10.1"
|
itertools = "0.10.1"
|
||||||
|
|
||||||
# TODO: update to 0.6 when out.
|
|
||||||
[dependencies.opaque-ke]
|
[dependencies.opaque-ke]
|
||||||
git = "https://github.com/novifinancial/opaque-ke"
|
version = "0.6"
|
||||||
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
|
|
||||||
|
[dependencies.lettre]
|
||||||
|
version = "0.10.0-rc.3"
|
||||||
|
features = [
|
||||||
|
"builder",
|
||||||
|
"serde",
|
||||||
|
"smtp-transport",
|
||||||
|
"tokio1-native-tls",
|
||||||
|
"tokio1",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies.sqlx]
|
[dependencies.sqlx]
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -72,5 +81,13 @@ features = ["with-chrono"]
|
|||||||
features = ["env", "toml"]
|
features = ["env", "toml"]
|
||||||
version = "*"
|
version = "*"
|
||||||
|
|
||||||
|
[dependencies.secstr]
|
||||||
|
features = ["serde"]
|
||||||
|
version = "*"
|
||||||
|
|
||||||
|
[dependencies.openssl-sys]
|
||||||
|
features = ["vendored"]
|
||||||
|
version = "*"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
mockall = "0.9.1"
|
mockall = "0.9.1"
|
||||||
|
|||||||
@@ -28,9 +28,18 @@ mockall::mock! {
|
|||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OpaqueHandler for TestOpaqueHandler {
|
impl OpaqueHandler for TestOpaqueHandler {
|
||||||
async fn login_start(&self, request: login::ClientLoginStartRequest) -> Result<login::ServerLoginStartResponse>;
|
async fn login_start(
|
||||||
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<String>;
|
&self,
|
||||||
async fn registration_start(&self, request: registration::ClientRegistrationStartRequest) -> Result<registration::ServerRegistrationStartResponse>;
|
request: login::ClientLoginStartRequest
|
||||||
async fn registration_finish(&self, request: registration::ClientRegistrationFinishRequest ) -> Result<()>;
|
) -> 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 sql_pool = get_in_memory_db().await;
|
||||||
let config = ConfigurationBuilder::default()
|
let config = ConfigurationBuilder::default()
|
||||||
.ldap_user_dn("admin".to_string())
|
.ldap_user_dn("admin".to_string())
|
||||||
.ldap_user_pass("test".to_string())
|
.ldap_user_pass(secstr::SecUtf8::from("test"))
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let handler = SqlBackendHandler::new(config, sql_pool);
|
let handler = SqlBackendHandler::new(config, sql_pool);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use async_trait::async_trait;
|
|||||||
use lldap_auth::opaque;
|
use lldap_auth::opaque;
|
||||||
use log::*;
|
use log::*;
|
||||||
use sea_query::{Expr, Iden, Query};
|
use sea_query::{Expr, Iden, Query};
|
||||||
|
use secstr::SecUtf8;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
type SqlOpaqueHandler = SqlBackendHandler;
|
type SqlOpaqueHandler = SqlBackendHandler;
|
||||||
@@ -83,7 +84,7 @@ impl SqlBackendHandler {
|
|||||||
impl LoginHandler for SqlBackendHandler {
|
impl LoginHandler for SqlBackendHandler {
|
||||||
async fn bind(&self, request: BindRequest) -> Result<()> {
|
async fn bind(&self, request: BindRequest) -> Result<()> {
|
||||||
if request.name == self.config.ldap_user_dn {
|
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(());
|
return Ok(());
|
||||||
} else {
|
} else {
|
||||||
debug!(r#"Invalid password for LDAP bind user"#);
|
debug!(r#"Invalid password for LDAP bind user"#);
|
||||||
@@ -220,11 +221,12 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
|||||||
pub(crate) async fn register_password(
|
pub(crate) async fn register_password(
|
||||||
opaque_handler: &SqlOpaqueHandler,
|
opaque_handler: &SqlOpaqueHandler,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &SecUtf8,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
use registration::*;
|
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
|
let start_response = opaque_handler
|
||||||
.registration_start(ClientRegistrationStartRequest {
|
.registration_start(ClientRegistrationStartRequest {
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
@@ -321,7 +323,7 @@ mod tests {
|
|||||||
attempt_login(&opaque_handler, "bob", "bob00")
|
attempt_login(&opaque_handler, "bob", "bob00")
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.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")
|
attempt_login(&opaque_handler, "bob", "wrong_password")
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use actix_web::{
|
|||||||
error::{ErrorBadRequest, ErrorUnauthorized},
|
error::{ErrorBadRequest, ErrorUnauthorized},
|
||||||
web, HttpRequest, HttpResponse,
|
web, HttpRequest, HttpResponse,
|
||||||
};
|
};
|
||||||
|
use actix_web_httpauth::extractors::bearer::BearerAuth;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use futures::future::{ok, Ready};
|
use futures::future::{ok, Ready};
|
||||||
@@ -22,6 +23,7 @@ use futures_util::{FutureExt, TryFutureExt};
|
|||||||
use hmac::Hmac;
|
use hmac::Hmac;
|
||||||
use jwt::{SignWithKey, VerifyWithKey};
|
use jwt::{SignWithKey, VerifyWithKey};
|
||||||
use lldap_auth::{login, registration, JWTClaims};
|
use lldap_auth::{login, registration, JWTClaims};
|
||||||
|
use log::*;
|
||||||
use sha2::Sha512;
|
use sha2::Sha512;
|
||||||
use std::collections::{hash_map::DefaultHasher, HashSet};
|
use std::collections::{hash_map::DefaultHasher, HashSet};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
@@ -101,7 +103,7 @@ where
|
|||||||
.cookie(
|
.cookie(
|
||||||
Cookie::build("token", token.as_str())
|
Cookie::build("token", token.as_str())
|
||||||
.max_age(1.days())
|
.max_age(1.days())
|
||||||
.path("/api")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.same_site(SameSite::Strict)
|
.same_site(SameSite::Strict)
|
||||||
.finish(),
|
.finish(),
|
||||||
@@ -111,6 +113,79 @@ where
|
|||||||
.unwrap_or_else(error_to_http_response)
|
.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>(
|
async fn get_logout<Backend>(
|
||||||
data: web::Data<AppState<Backend>>,
|
data: web::Data<AppState<Backend>>,
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
@@ -148,7 +223,7 @@ where
|
|||||||
.cookie(
|
.cookie(
|
||||||
Cookie::build("token", "")
|
Cookie::build("token", "")
|
||||||
.max_age(0.days())
|
.max_age(0.days())
|
||||||
.path("/api")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.same_site(SameSite::Strict)
|
.same_site(SameSite::Strict)
|
||||||
.finish(),
|
.finish(),
|
||||||
@@ -203,7 +278,7 @@ where
|
|||||||
.cookie(
|
.cookie(
|
||||||
Cookie::build("token", token.as_str())
|
Cookie::build("token", token.as_str())
|
||||||
.max_age(1.days())
|
.max_age(1.days())
|
||||||
.path("/api")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.same_site(SameSite::Strict)
|
.same_site(SameSite::Strict)
|
||||||
.finish(),
|
.finish(),
|
||||||
@@ -254,14 +329,45 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn opaque_register_start<Backend>(
|
async fn opaque_register_start<Backend>(
|
||||||
|
request: actix_web::HttpRequest,
|
||||||
|
mut payload: actix_web::web::Payload,
|
||||||
data: web::Data<AppState<Backend>>,
|
data: web::Data<AppState<Backend>>,
|
||||||
request: web::Json<registration::ClientRegistrationStartRequest>,
|
|
||||||
) -> ApiResult<registration::ServerRegistrationStartResponse>
|
) -> ApiResult<registration::ServerRegistrationStartResponse>
|
||||||
where
|
where
|
||||||
Backend: OpaqueHandler + 'static,
|
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
|
data.backend_handler
|
||||||
.registration_start(request.into_inner())
|
.registration_start(registration_start_request)
|
||||||
.await
|
.await
|
||||||
.map(|res| ApiResult::Left(web::Json(res)))
|
.map(|res| ApiResult::Left(web::Json(res)))
|
||||||
.unwrap_or_else(error_to_api_response)
|
.unwrap_or_else(error_to_api_response)
|
||||||
@@ -402,14 +508,25 @@ where
|
|||||||
web::resource("/opaque/login/finish")
|
web::resource("/opaque/login/finish")
|
||||||
.route(web::post().to(opaque_login_finish::<Backend>)),
|
.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("/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 clap::Clap;
|
||||||
|
use lettre::message::Mailbox;
|
||||||
|
|
||||||
/// lldap is a lightweight LDAP server
|
/// lldap is a lightweight LDAP server
|
||||||
#[derive(Debug, Clap, Clone)]
|
#[derive(Debug, Clap, Clone)]
|
||||||
@@ -9,6 +10,7 @@ pub struct CLIOpts {
|
|||||||
pub command: Command,
|
pub command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Debug, Clap, Clone)]
|
#[derive(Debug, Clap, Clone)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Export the GraphQL schema to *.graphql.
|
/// Export the GraphQL schema to *.graphql.
|
||||||
@@ -17,25 +19,100 @@ pub enum Command {
|
|||||||
/// Run the LDAP and GraphQL server.
|
/// Run the LDAP and GraphQL server.
|
||||||
#[clap(name = "run")]
|
#[clap(name = "run")]
|
||||||
Run(RunOpts),
|
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)]
|
#[derive(Debug, Clap, Clone)]
|
||||||
pub struct RunOpts {
|
pub struct RunOpts {
|
||||||
/// Change config file name
|
#[clap(flatten)]
|
||||||
#[clap(short, long, default_value = "lldap_config.toml")]
|
pub general_config: GeneralConfigOpts,
|
||||||
pub config_file: String,
|
|
||||||
|
|
||||||
/// Change ldap port. Default: 389
|
/// Path to the file that contains the private server key.
|
||||||
#[clap(long)]
|
/// 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>,
|
pub ldap_port: Option<u16>,
|
||||||
|
|
||||||
/// Change ldap ssl port. Default: 636
|
/// Change ldap ssl port. Default: 6360
|
||||||
#[clap(long)]
|
#[clap(long, env = "LLDAP_LDAPS_PORT")]
|
||||||
pub ldaps_port: Option<u16>,
|
pub ldaps_port: Option<u16>,
|
||||||
|
|
||||||
/// Set verbose logging
|
/// Change HTTP API port. Default: 17170
|
||||||
#[clap(short, long)]
|
#[clap(long, env = "LLDAP_HTTP_PORT")]
|
||||||
pub verbose: bool,
|
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)]
|
#[derive(Debug, Clap, Clone)]
|
||||||
|
|||||||
@@ -1,49 +1,83 @@
|
|||||||
|
use crate::infra::cli::{GeneralConfigOpts, RunOpts, SmtpOpts, TestEmailOpts};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use figment::{
|
use figment::{
|
||||||
providers::{Env, Format, Serialized, Toml},
|
providers::{Env, Format, Serialized, Toml},
|
||||||
Figment,
|
Figment,
|
||||||
};
|
};
|
||||||
|
use lettre::message::Mailbox;
|
||||||
use lldap_auth::opaque::{server::ServerSetup, KeyPair};
|
use lldap_auth::opaque::{server::ServerSetup, KeyPair};
|
||||||
use log::*;
|
use secstr::SecUtf8;
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
|
||||||
#[builder(
|
#[builder(pattern = "owned", build_fn(name = "private_build"))]
|
||||||
pattern = "owned",
|
|
||||||
default = "Configuration::default()",
|
|
||||||
build_fn(name = "private_build", validate = "Self::validate")
|
|
||||||
)]
|
|
||||||
pub struct Configuration {
|
pub struct Configuration {
|
||||||
|
#[builder(default = "3890")]
|
||||||
pub ldap_port: u16,
|
pub ldap_port: u16,
|
||||||
|
#[builder(default = "6360")]
|
||||||
pub ldaps_port: u16,
|
pub ldaps_port: u16,
|
||||||
|
#[builder(default = "17170")]
|
||||||
pub http_port: u16,
|
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,
|
pub ldap_base_dn: String,
|
||||||
|
#[builder(default = r#"String::from("admin")"#)]
|
||||||
pub ldap_user_dn: String,
|
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,
|
pub database_url: String,
|
||||||
|
#[builder(default = "false")]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
|
#[builder(default = r#"String::from("server_key")"#)]
|
||||||
pub key_file: String,
|
pub key_file: String,
|
||||||
|
#[builder(default)]
|
||||||
|
pub smtp_options: MailOptions,
|
||||||
|
#[builder(default = r#"String::from("http://localhost")"#)]
|
||||||
|
pub http_url: String,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
#[builder(field(private), setter(strip_option))]
|
#[builder(field(private), default = "None")]
|
||||||
server_setup: Option<ServerSetup>,
|
server_setup: Option<ServerSetup>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::default::Default for Configuration {
|
||||||
|
fn default() -> Self {
|
||||||
|
ConfigurationBuilder::default().build().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ConfigurationBuilder {
|
impl ConfigurationBuilder {
|
||||||
#[cfg(test)]
|
|
||||||
pub fn build(self) -> Result<Configuration> {
|
pub fn build(self) -> Result<Configuration> {
|
||||||
let server_setup = get_server_setup(self.key_file.as_deref().unwrap_or("server_key"))?;
|
let server_setup = get_server_setup(self.key_file.as_deref().unwrap_or("server_key"))?;
|
||||||
Ok(self.server_setup(server_setup).private_build()?)
|
Ok(self.server_setup(Some(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(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,39 +89,6 @@ impl Configuration {
|
|||||||
pub fn get_server_keys(&self) -> &KeyPair {
|
pub fn get_server_keys(&self) -> &KeyPair {
|
||||||
self.get_server_setup().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> {
|
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> {
|
pub trait ConfigOverrider {
|
||||||
let config_file = cli_opts.config_file.clone();
|
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()))
|
impl TopLevelCommandOpts for RunOpts {
|
||||||
.merge(Toml::file(config_file))
|
fn general_config(&self) -> &GeneralConfigOpts {
|
||||||
.merge(Env::prefixed("LLDAP_"))
|
&self.general_config
|
||||||
.extract()?;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)?);
|
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)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::domain::handler::{BackendHandler, GroupId, GroupIdAndName};
|
use crate::domain::handler::{BackendHandler, GroupId, GroupIdAndName};
|
||||||
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
|
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::convert::TryInto;
|
|
||||||
|
|
||||||
type DomainRequestFilter = crate::domain::handler::RequestFilter;
|
type DomainRequestFilter = crate::domain::handler::RequestFilter;
|
||||||
type DomainUser = crate::domain::handler::User;
|
type DomainUser = crate::domain::handler::User;
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ pub enum JwtStorage {
|
|||||||
Blacklisted,
|
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.
|
/// This needs to be initialized after the domain tables are.
|
||||||
pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -95,5 +104,38 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,16 @@
|
|||||||
use crate::domain::handler::{BackendHandler, LoginHandler};
|
use crate::{
|
||||||
use crate::infra::configuration::Configuration;
|
domain::{
|
||||||
use crate::infra::ldap_handler::LdapHandler;
|
handler::{BackendHandler, LoginHandler},
|
||||||
|
opaque_handler::OpaqueHandler,
|
||||||
|
},
|
||||||
|
infra::{configuration::Configuration, ldap_handler::LdapHandler},
|
||||||
|
};
|
||||||
use actix_rt::net::TcpStream;
|
use actix_rt::net::TcpStream;
|
||||||
use actix_server::ServerBuilder;
|
use actix_server::ServerBuilder;
|
||||||
use actix_service::{fn_service, ServiceFactoryExt};
|
use actix_service::{fn_service, ServiceFactoryExt};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{Context, Result};
|
||||||
use futures_util::future::ok;
|
use futures_util::future::ok;
|
||||||
use ldap3_server::simple::*;
|
use ldap3_server::{proto::LdapMsg, LdapCodec};
|
||||||
use ldap3_server::LdapCodec;
|
|
||||||
use log::*;
|
use log::*;
|
||||||
use tokio::net::tcp::WriteHalf;
|
use tokio::net::tcp::WriteHalf;
|
||||||
use tokio_util::codec::{FramedRead, FramedWrite};
|
use tokio_util::codec::{FramedRead, FramedWrite};
|
||||||
@@ -18,36 +21,31 @@ async fn handle_incoming_message<Backend>(
|
|||||||
session: &mut LdapHandler<Backend>,
|
session: &mut LdapHandler<Backend>,
|
||||||
) -> Result<bool>
|
) -> Result<bool>
|
||||||
where
|
where
|
||||||
Backend: BackendHandler + LoginHandler,
|
Backend: BackendHandler + LoginHandler + OpaqueHandler,
|
||||||
{
|
{
|
||||||
use futures_util::SinkExt;
|
use futures_util::SinkExt;
|
||||||
use std::convert::TryFrom;
|
let msg = msg.context("while receiving LDAP op")?;
|
||||||
let server_op = match msg.map_err(|_e| ()).and_then(ServerOps::try_from) {
|
debug!("Received LDAP message: {:?}", &msg);
|
||||||
Ok(a_value) => a_value,
|
match session.handle_ldap_message(msg.op).await {
|
||||||
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 {
|
|
||||||
None => return Ok(false),
|
None => return Ok(false),
|
||||||
Some(result) => {
|
Some(result) => {
|
||||||
for rmsg in result.into_iter() {
|
if result.is_empty() {
|
||||||
if let Err(e) = resp.send(rmsg).await {
|
debug!("No response");
|
||||||
bail!("Error while sending a response: {:?}", e);
|
}
|
||||||
}
|
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 {
|
resp.flush()
|
||||||
bail!("Error while flushing responses: {:?}", e);
|
.await
|
||||||
}
|
.context("while flushing responses: {:#}")?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -59,14 +57,14 @@ pub fn build_ldap_server<Backend>(
|
|||||||
server_builder: ServerBuilder,
|
server_builder: ServerBuilder,
|
||||||
) -> Result<ServerBuilder>
|
) -> Result<ServerBuilder>
|
||||||
where
|
where
|
||||||
Backend: BackendHandler + LoginHandler + 'static,
|
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
|
||||||
{
|
{
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
let ldap_base_dn = config.ldap_base_dn.clone();
|
let ldap_base_dn = config.ldap_base_dn.clone();
|
||||||
let ldap_user_dn = config.ldap_user_dn.clone();
|
let ldap_user_dn = config.ldap_user_dn.clone();
|
||||||
Ok(
|
server_builder
|
||||||
server_builder.bind("ldap", ("0.0.0.0", config.ldap_port), move || {
|
.bind("ldap", ("0.0.0.0", config.ldap_port), move || {
|
||||||
let backend_handler = backend_handler.clone();
|
let backend_handler = backend_handler.clone();
|
||||||
let ldap_base_dn = ldap_base_dn.clone();
|
let ldap_base_dn = ldap_base_dn.clone();
|
||||||
let ldap_user_dn = ldap_user_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);
|
let mut session = LdapHandler::new(backend_handler, ldap_base_dn, ldap_user_dn);
|
||||||
|
|
||||||
while let Some(msg) = requests.next().await {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,12 +92,11 @@ where
|
|||||||
Ok(stream)
|
Ok(stream)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_err(|err: anyhow::Error| error!("Service Error: {:?}", err))
|
.map_err(|err: anyhow::Error| error!("Service Error: {:#}", err))
|
||||||
// catch
|
|
||||||
.and_then(move |_| {
|
.and_then(move |_| {
|
||||||
// finally
|
// finally
|
||||||
ok(())
|
ok(())
|
||||||
})
|
})
|
||||||
})?,
|
})
|
||||||
)
|
.with_context(|| format!("while binding to the port {}", config.ldap_port))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
use crate::infra::configuration::Configuration;
|
use crate::infra::configuration::Configuration;
|
||||||
use anyhow::Context;
|
use tracing_subscriber::prelude::*;
|
||||||
use tracing::subscriber::set_global_default;
|
|
||||||
use tracing_log::LogTracer;
|
|
||||||
|
|
||||||
pub fn init(config: Configuration) -> anyhow::Result<()> {
|
pub fn init(config: &Configuration) -> anyhow::Result<()> {
|
||||||
let max_log_level = log_level_from_config(config);
|
let max_log_level = log_level_from_config(config);
|
||||||
let subscriber = tracing_subscriber::fmt()
|
let sqlx_max_log_level = sqlx_log_level_from_config(config);
|
||||||
.with_timer(tracing_subscriber::fmt::time::time())
|
let filter = tracing_subscriber::filter::Targets::new()
|
||||||
.with_target(false)
|
.with_target("lldap", max_log_level)
|
||||||
.with_level(true)
|
.with_target("sqlx", sqlx_max_log_level);
|
||||||
.with_max_level(max_log_level)
|
tracing_subscriber::registry()
|
||||||
.finish();
|
.with(tracing_subscriber::fmt::layer().with_filter(filter))
|
||||||
LogTracer::init().context("Failed to set logger")?;
|
.init();
|
||||||
set_global_default(subscriber).context("Failed to set subscriber")?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_level_from_config(config: Configuration) -> tracing::Level {
|
fn log_level_from_config(config: &Configuration) -> tracing::Level {
|
||||||
if config.verbose {
|
if config.verbose {
|
||||||
tracing::Level::DEBUG
|
tracing::Level::DEBUG
|
||||||
} else {
|
} else {
|
||||||
tracing::Level::INFO
|
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_handler;
|
||||||
pub mod ldap_server;
|
pub mod ldap_server;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod mail;
|
||||||
pub mod sql_backend_handler;
|
pub mod sql_backend_handler;
|
||||||
pub mod tcp_backend_handler;
|
pub mod tcp_backend_handler;
|
||||||
pub mod tcp_server;
|
pub mod tcp_server;
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ use sea_query::{Expr, Iden, Query, SimpleExpr};
|
|||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::collections::HashSet;
|
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]
|
#[async_trait]
|
||||||
impl TcpBackendHandler for SqlBackendHandler {
|
impl TcpBackendHandler for SqlBackendHandler {
|
||||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>> {
|
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>> {
|
||||||
use sqlx::Result;
|
|
||||||
let query = Query::select()
|
let query = Query::select()
|
||||||
.column(JwtStorage::JwtHash)
|
.column(JwtStorage::JwtHash)
|
||||||
.from(JwtStorage::Table)
|
.from(JwtStorage::Table)
|
||||||
@@ -21,21 +30,15 @@ impl TcpBackendHandler for SqlBackendHandler {
|
|||||||
.collect::<Vec<sqlx::Result<u64>>>()
|
.collect::<Vec<sqlx::Result<u64>>>()
|
||||||
.await
|
.await
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<Result<HashSet<u64>>>()
|
.collect::<sqlx::Result<HashSet<u64>>>()
|
||||||
.map_err(|e| anyhow::anyhow!(e))
|
.map_err(|e| anyhow::anyhow!(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)> {
|
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::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
// TODO: Initialize the rng only once. Maybe Arc<Cell>?
|
// TODO: Initialize the rng only once. Maybe Arc<Cell>?
|
||||||
let mut rng = SmallRng::from_entropy();
|
let refresh_token = gen_random_string(100);
|
||||||
let refresh_token: String = std::iter::repeat(())
|
|
||||||
.map(|()| rng.sample(Alphanumeric))
|
|
||||||
.map(char::from)
|
|
||||||
.take(100)
|
|
||||||
.collect();
|
|
||||||
let refresh_token_hash = {
|
let refresh_token_hash = {
|
||||||
let mut s = DefaultHasher::new();
|
let mut s = DefaultHasher::new();
|
||||||
refresh_token.hash(&mut s);
|
refresh_token.hash(&mut s);
|
||||||
@@ -71,7 +74,7 @@ impl TcpBackendHandler for SqlBackendHandler {
|
|||||||
.await?
|
.await?
|
||||||
.is_some())
|
.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;
|
use sqlx::Result;
|
||||||
let query = Query::select()
|
let query = Query::select()
|
||||||
.column(JwtStorage::JwtHash)
|
.column(JwtStorage::JwtHash)
|
||||||
@@ -94,7 +97,7 @@ impl TcpBackendHandler for SqlBackendHandler {
|
|||||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||||
Ok(result?)
|
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()
|
let query = Query::delete()
|
||||||
.from_table(JwtRefreshStorage::Table)
|
.from_table(JwtRefreshStorage::Table)
|
||||||
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash))
|
.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?;
|
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||||
Ok(())
|
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 async_trait::async_trait;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
pub type DomainResult<T> = crate::domain::error::Result<T>;
|
use crate::domain::error::Result;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait TcpBackendHandler {
|
pub trait TcpBackendHandler {
|
||||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
||||||
async fn create_refresh_token(&self, user: &str) -> DomainResult<(String, chrono::Duration)>;
|
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)>;
|
||||||
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> DomainResult<bool>;
|
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
|
||||||
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>>;
|
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>>;
|
||||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()>;
|
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)]
|
#[cfg(test)]
|
||||||
@@ -22,30 +31,33 @@ mockall::mock! {
|
|||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LoginHandler for TestTcpBackendHandler {
|
impl LoginHandler for TestTcpBackendHandler {
|
||||||
async fn bind(&self, request: BindRequest) -> DomainResult<()>;
|
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl BackendHandler for TestTcpBackendHandler {
|
impl BackendHandler for TestTcpBackendHandler {
|
||||||
async fn list_users(&self, filters: Option<RequestFilter>) -> DomainResult<Vec<User>>;
|
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
|
||||||
async fn list_groups(&self) -> DomainResult<Vec<Group>>;
|
async fn list_groups(&self) -> Result<Vec<Group>>;
|
||||||
async fn get_user_details(&self, user_id: &str) -> DomainResult<User>;
|
async fn get_user_details(&self, user_id: &str) -> Result<User>;
|
||||||
async fn get_group_details(&self, group_id: GroupId) -> DomainResult<GroupIdAndName>;
|
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
|
||||||
async fn get_user_groups(&self, user: &str) -> DomainResult<HashSet<GroupIdAndName>>;
|
async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
|
||||||
async fn create_user(&self, request: CreateUserRequest) -> DomainResult<()>;
|
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||||
async fn update_user(&self, request: UpdateUserRequest) -> DomainResult<()>;
|
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||||
async fn update_group(&self, request: UpdateGroupRequest) -> DomainResult<()>;
|
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||||
async fn delete_user(&self, user_id: &str) -> DomainResult<()>;
|
async fn delete_user(&self, user_id: &str) -> Result<()>;
|
||||||
async fn create_group(&self, group_name: &str) -> DomainResult<GroupId>;
|
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||||
async fn delete_group(&self, group_id: GroupId) -> DomainResult<()>;
|
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||||
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> DomainResult<()>;
|
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) -> DomainResult<()>;
|
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TcpBackendHandler for TestTcpBackendHandler {
|
impl TcpBackendHandler for TestTcpBackendHandler {
|
||||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
||||||
async fn create_refresh_token(&self, user: &str) -> DomainResult<(String, chrono::Duration)>;
|
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)>;
|
||||||
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> DomainResult<bool>;
|
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
|
||||||
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>>;
|
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>>;
|
||||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()>;
|
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},
|
handler::{BackendHandler, LoginHandler},
|
||||||
opaque_handler::OpaqueHandler,
|
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_files::{Files, NamedFile};
|
||||||
use actix_http::HttpServiceBuilder;
|
use actix_http::HttpServiceBuilder;
|
||||||
@@ -44,15 +48,19 @@ pub(crate) fn error_to_http_response(error: DomainError) -> HttpResponse {
|
|||||||
fn http_config<Backend>(
|
fn http_config<Backend>(
|
||||||
cfg: &mut web::ServiceConfig,
|
cfg: &mut web::ServiceConfig,
|
||||||
backend_handler: Backend,
|
backend_handler: Backend,
|
||||||
jwt_secret: String,
|
jwt_secret: secstr::SecUtf8,
|
||||||
jwt_blacklist: HashSet<u64>,
|
jwt_blacklist: HashSet<u64>,
|
||||||
|
server_url: String,
|
||||||
|
mail_options: MailOptions,
|
||||||
) where
|
) where
|
||||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
|
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
|
||||||
{
|
{
|
||||||
cfg.app_data(web::Data::new(AppState::<Backend> {
|
cfg.app_data(web::Data::new(AppState::<Backend> {
|
||||||
backend_handler,
|
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),
|
jwt_blacklist: RwLock::new(jwt_blacklist),
|
||||||
|
server_url,
|
||||||
|
mail_options,
|
||||||
}))
|
}))
|
||||||
// Serve index.html and main.js, and default to index.html.
|
// Serve index.html and main.js, and default to index.html.
|
||||||
.route(
|
.route(
|
||||||
@@ -76,6 +84,8 @@ pub(crate) struct AppState<Backend> {
|
|||||||
pub backend_handler: Backend,
|
pub backend_handler: Backend,
|
||||||
pub jwt_key: Hmac<Sha512>,
|
pub jwt_key: Hmac<Sha512>,
|
||||||
pub jwt_blacklist: RwLock<HashSet<u64>>,
|
pub jwt_blacklist: RwLock<HashSet<u64>>,
|
||||||
|
pub server_url: String,
|
||||||
|
pub mail_options: MailOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build_tcp_server<Backend>(
|
pub async fn build_tcp_server<Backend>(
|
||||||
@@ -87,16 +97,30 @@ where
|
|||||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
|
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
|
||||||
{
|
{
|
||||||
let jwt_secret = config.jwt_secret.clone();
|
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
|
server_builder
|
||||||
.bind("http", ("0.0.0.0", config.http_port), move || {
|
.bind("http", ("0.0.0.0", config.http_port), move || {
|
||||||
let backend_handler = backend_handler.clone();
|
let backend_handler = backend_handler.clone();
|
||||||
let jwt_secret = jwt_secret.clone();
|
let jwt_secret = jwt_secret.clone();
|
||||||
let jwt_blacklist = jwt_blacklist.clone();
|
let jwt_blacklist = jwt_blacklist.clone();
|
||||||
|
let server_url = server_url.clone();
|
||||||
|
let mail_options = mail_options.clone();
|
||||||
HttpServiceBuilder::new()
|
HttpServiceBuilder::new()
|
||||||
.finish(map_config(
|
.finish(map_config(
|
||||||
App::new().configure(move |cfg| {
|
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(),
|
|_| AppConfig::default(),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
#![forbid(non_ascii_idents)]
|
||||||
#![allow(clippy::nonstandard_macro_braces)]
|
#![allow(clippy::nonstandard_macro_braces)]
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -8,7 +9,7 @@ use crate::{
|
|||||||
sql_opaque_handler::register_password,
|
sql_opaque_handler::register_password,
|
||||||
sql_tables::PoolOptions,
|
sql_tables::PoolOptions,
|
||||||
},
|
},
|
||||||
infra::{cli::*, configuration::Configuration, db_cleaner::Scheduler},
|
infra::{cli::*, configuration::Configuration, db_cleaner::Scheduler, mail},
|
||||||
};
|
};
|
||||||
use actix::Actor;
|
use actix::Actor;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
@@ -19,10 +20,11 @@ mod domain;
|
|||||||
mod infra;
|
mod infra;
|
||||||
|
|
||||||
async fn create_admin_user(handler: &SqlBackendHandler, config: &Configuration) -> Result<()> {
|
async fn create_admin_user(handler: &SqlBackendHandler, config: &Configuration) -> Result<()> {
|
||||||
|
let pass_length = config.ldap_user_pass.unsecure().len();
|
||||||
assert!(
|
assert!(
|
||||||
config.ldap_user_pass.len() >= 8,
|
pass_length >= 8,
|
||||||
"Minimum password length is 8 characters, got {} characters",
|
"Minimum password length is 8 characters, got {} characters",
|
||||||
config.ldap_user_pass.len()
|
pass_length
|
||||||
);
|
);
|
||||||
handler
|
handler
|
||||||
.create_user(CreateUserRequest {
|
.create_user(CreateUserRequest {
|
||||||
@@ -47,51 +49,69 @@ async fn run_server(config: Configuration) -> Result<()> {
|
|||||||
let sql_pool = PoolOptions::new()
|
let sql_pool = PoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
.connect(&config.database_url)
|
.connect(&config.database_url)
|
||||||
.await?;
|
.await
|
||||||
domain::sql_tables::init_table(&sql_pool).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());
|
let backend_handler = SqlBackendHandler::new(config.clone(), sql_pool.clone());
|
||||||
if let Err(e) = backend_handler.get_user_details(&config.ldap_user_dn).await {
|
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);
|
warn!("Could not get admin user, trying to create it: {:#}", e);
|
||||||
create_admin_user(&backend_handler, &config)
|
create_admin_user(&backend_handler, &config)
|
||||||
.await
|
.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(
|
let server_builder = infra::ldap_server::build_ldap_server(
|
||||||
&config,
|
&config,
|
||||||
backend_handler.clone(),
|
backend_handler.clone(),
|
||||||
actix_server::Server::build(),
|
actix_server::Server::build(),
|
||||||
)?;
|
)
|
||||||
|
.context("while binding the LDAP server")?;
|
||||||
infra::jwt_sql_tables::init_table(&sql_pool).await?;
|
infra::jwt_sql_tables::init_table(&sql_pool).await?;
|
||||||
let server_builder =
|
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.
|
// Run every hour.
|
||||||
let scheduler = Scheduler::new("0 0 * * * * *", sql_pool);
|
let scheduler = Scheduler::new("0 0 * * * * *", sql_pool);
|
||||||
scheduler.start();
|
scheduler.start();
|
||||||
server_builder.workers(1).run().await?;
|
server_builder
|
||||||
|
.workers(1)
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
.context("while starting the server")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_server_command(opts: RunOpts) -> Result<()> {
|
fn run_server_command(opts: RunOpts) -> Result<()> {
|
||||||
let config = infra::configuration::init(opts.clone())?;
|
debug!("CLI: {:#?}", &opts);
|
||||||
infra::logging::init(config.clone())?;
|
|
||||||
|
let config = infra::configuration::init(opts)?;
|
||||||
|
infra::logging::init(&config)?;
|
||||||
|
|
||||||
info!("Starting LLDAP....");
|
info!("Starting LLDAP....");
|
||||||
|
|
||||||
debug!("CLI: {:#?}", opts);
|
|
||||||
debug!("Configuration: {:#?}", config);
|
|
||||||
|
|
||||||
actix::run(
|
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.");
|
info!("End.");
|
||||||
Ok(())
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
let cli_opts = infra::cli::init();
|
let cli_opts = infra::cli::init();
|
||||||
match cli_opts.command {
|
match cli_opts.command {
|
||||||
Command::ExportGraphQLSchema(opts) => infra::graphql::api::export_schema(opts),
|
Command::ExportGraphQLSchema(opts) => infra::graphql::api::export_schema(opts),
|
||||||
Command::Run(opts) => run_server_command(opts),
|
Command::Run(opts) => run_server_command(opts),
|
||||||
|
Command::SendTestEmail(opts) => send_test_email_command(opts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user