Compare commits
432 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f417427635 | ||
|
|
1f26262e13 | ||
|
|
42fccf4713 | ||
|
|
928faa4bcc | ||
|
|
3895a5050d | ||
|
|
f92035b6fd | ||
|
|
37a10c871f | ||
|
|
8397d536d9 | ||
|
|
acd39d20b1 | ||
|
|
0ddeab8caa | ||
|
|
64514ddfc6 | ||
|
|
c47be779a3 | ||
|
|
fea2ed5b79 | ||
|
|
e982908768 | ||
|
|
713dbde4cb | ||
|
|
579dd5e1b6 | ||
|
|
3828ec7624 | ||
|
|
b8c06ebd75 | ||
|
|
130d2552ac | ||
|
|
098745ebc9 | ||
|
|
95337e2cd8 | ||
|
|
143eb70bee | ||
|
|
35fe521cbe | ||
|
|
c8601b9169 | ||
|
|
8f6c324de7 | ||
|
|
f0fcc88f1d | ||
|
|
c08ddecd32 | ||
|
|
4ebfd0525b | ||
|
|
a190fe7ddf | ||
|
|
df188ee83f | ||
|
|
52c917d967 | ||
|
|
f01daae6a8 | ||
|
|
62b2afa283 | ||
|
|
305b272cdf | ||
|
|
a95ac38083 | ||
|
|
abfe2f3a17 | ||
|
|
11d766b2ba | ||
|
|
56eee6908e | ||
|
|
dcb45d4f6b | ||
|
|
a6eac55fc7 | ||
|
|
1c6646d8c5 | ||
|
|
362e968e00 | ||
|
|
17bcd7645b | ||
|
|
dcba3d17dc | ||
|
|
1f3f73585b | ||
|
|
0c6a92a8fa | ||
|
|
120ad34f92 | ||
|
|
a2ba71ac19 | ||
|
|
10a820f2a2 | ||
|
|
01f97f5ed4 | ||
|
|
f14aa2284c | ||
|
|
65e2103365 | ||
|
|
5db0072cfa | ||
|
|
1d8d3eb73f | ||
|
|
97e4d90eb7 | ||
|
|
6cf0f6df06 | ||
|
|
b1384818d2 | ||
|
|
3ec44a58be | ||
|
|
6f7bfca682 | ||
|
|
2c79a40a73 | ||
|
|
25c6d6c962 | ||
|
|
04b048dd47 | ||
|
|
dc26f97117 | ||
|
|
09c5d9f925 | ||
|
|
ee7f9c9f41 | ||
|
|
fa9c503de7 | ||
|
|
4138963bee | ||
|
|
5a2a92bbda | ||
|
|
6aa9303339 | ||
|
|
049a360506 | ||
|
|
b26de34e0d | ||
|
|
15c28110b5 | ||
|
|
83508a363c | ||
|
|
010eec22d3 | ||
|
|
b33d56a459 | ||
|
|
6eb5b959bf | ||
|
|
6f46ffd1e4 | ||
|
|
73686224dd | ||
|
|
56ed37ef8a | ||
|
|
39e1a02255 | ||
|
|
4f050cded5 | ||
|
|
254a168e78 | ||
|
|
85b83aff5f | ||
|
|
199a80ca5b | ||
|
|
f96868318a | ||
|
|
04b0fa0ae9 | ||
|
|
2e08c6a7ec | ||
|
|
892492815d | ||
|
|
2597a250f0 | ||
|
|
f67f090bde | ||
|
|
a97881477f | ||
|
|
8587fc38fd | ||
|
|
6d65a2546c | ||
|
|
7806ed34ff | ||
|
|
22623bfab1 | ||
|
|
2f20f63b41 | ||
|
|
87d825626c | ||
|
|
8cbad6d5bd | ||
|
|
8db7d8a46f | ||
|
|
533d1bcfd0 | ||
|
|
3d8aafaa9d | ||
|
|
f93681239b | ||
|
|
13720c101c | ||
|
|
a1eb708cf3 | ||
|
|
959bb907d8 | ||
|
|
22074f56d2 | ||
|
|
5c5b87d5af | ||
|
|
f65a6f524a | ||
|
|
96f5b31e0c | ||
|
|
4955b7fac1 | ||
|
|
646fe32645 | ||
|
|
fa9743be6a | ||
|
|
38c4296d62 | ||
|
|
1c65cd115e | ||
|
|
8f2391a792 | ||
|
|
bb2654f9c2 | ||
|
|
770e934859 | ||
|
|
cc0827f271 | ||
|
|
93f3057b8f | ||
|
|
206e98c986 | ||
|
|
28e6fa0f10 | ||
|
|
d4b3b4649e | ||
|
|
b78e093205 | ||
|
|
c2eed8909a | ||
|
|
b82a2d5705 | ||
|
|
addd453287 | ||
|
|
e308a5e9a1 | ||
|
|
1f2f034a48 | ||
|
|
bd0a58b476 | ||
|
|
4adb636d53 | ||
|
|
6f905b1ca9 | ||
|
|
2ea17c04ba | ||
|
|
10609b25e9 | ||
|
|
9f8364ca1a | ||
|
|
56078c0b47 | ||
|
|
8b7852bf1c | ||
|
|
c4be7f5b6f | ||
|
|
337101edea | ||
|
|
dc140f1675 | ||
|
|
f74f88f0c0 | ||
|
|
708d927e90 | ||
|
|
0d48b7f8c9 | ||
|
|
f2b1e73929 | ||
|
|
997119cdcf | ||
|
|
a147085a2f | ||
|
|
f363ff9437 | ||
|
|
b6e6269956 | ||
|
|
ff0ea51121 | ||
|
|
9ac96e8c6e | ||
|
|
63f802648f | ||
|
|
1aba962cd3 | ||
|
|
06697a5305 | ||
|
|
5a5d5b1d0e | ||
|
|
2e0d65e665 | ||
|
|
2c54ad895d | ||
|
|
272c84c574 | ||
|
|
71d37b9e5e | ||
|
|
c55e0f3bcf | ||
|
|
f2946e6cf6 | ||
|
|
f3e2f8c52d | ||
|
|
70d85524db | ||
|
|
ec0737c58a | ||
|
|
33f50d13a2 | ||
|
|
5cd4499328 | ||
|
|
a65ad14349 | ||
|
|
2ca5e9e720 | ||
|
|
4f72153bd4 | ||
|
|
829c3f2bb1 | ||
|
|
a6481dde56 | ||
|
|
35146ac904 | ||
|
|
d488802e68 | ||
|
|
927c79bb55 | ||
|
|
3b6f24dd17 | ||
|
|
8ab900dfce | ||
|
|
504227eb13 | ||
|
|
1b97435853 | ||
|
|
1fddd87470 | ||
|
|
af8277dbbd | ||
|
|
609d0ddb7d | ||
|
|
3df42ae707 | ||
|
|
8f9520b640 | ||
|
|
7c9f61e2eb | ||
|
|
5275af8f96 | ||
|
|
0db41f6278 | ||
|
|
4574538c76 | ||
|
|
9d5714ee0b | ||
|
|
c6ecf8d58a | ||
|
|
9e88bfe6b4 | ||
|
|
5bd81780b3 | ||
|
|
4fd71ff02f | ||
|
|
f0046692b8 | ||
|
|
439fde434b | ||
|
|
2a5fd01439 | ||
|
|
2c398d0e8e | ||
|
|
93e9985a81 | ||
|
|
ed3be02384 | ||
|
|
3fadfb1944 | ||
|
|
81204dcee5 | ||
|
|
39a75b2c35 | ||
|
|
8e1515c27b | ||
|
|
ddfd719884 | ||
|
|
6f04530700 | ||
|
|
caf67fdf2b | ||
|
|
034794d58d | ||
|
|
e53ce92c96 | ||
|
|
630ac5fd8c | ||
|
|
b269fa0fc7 | ||
|
|
208cc7192e | ||
|
|
80e9145a4f | ||
|
|
78d370d3f4 | ||
|
|
f279a14693 | ||
|
|
b54bf3c4d5 | ||
|
|
582abba793 | ||
|
|
94da42ffb9 | ||
|
|
08d3aef177 | ||
|
|
7671b61a6b | ||
|
|
47b308f9b7 | ||
|
|
1a5931c3df | ||
|
|
b3d771e063 | ||
|
|
134796aa9f | ||
|
|
1598f096e9 | ||
|
|
99ed6eface | ||
|
|
ce6bf7c548 | ||
|
|
5677ff798f | ||
|
|
e47004097a | ||
|
|
5e3a4f3446 | ||
|
|
8e61ee60d5 | ||
|
|
a426453d7f | ||
|
|
1ac9bd0e68 | ||
|
|
a83c305e51 | ||
|
|
7b171cf59a | ||
|
|
b237c71b99 | ||
|
|
2eff37684d | ||
|
|
836823a5cd | ||
|
|
e1d4df0b04 | ||
|
|
70bbe7f5ad | ||
|
|
6d796df097 | ||
|
|
6cd6b412fe | ||
|
|
042429a11d | ||
|
|
c440df631f | ||
|
|
3247ffc8ea | ||
|
|
ef17c280b1 | ||
|
|
d0cdfa97c7 | ||
|
|
f0bbcfd2c8 | ||
|
|
08b7c6ce33 | ||
|
|
719708dfd0 | ||
|
|
b82cb83318 | ||
|
|
d9f4adcb0e | ||
|
|
e5bc06a617 | ||
|
|
af49871801 | ||
|
|
7d1f5abc13 | ||
|
|
31a8ba24a0 | ||
|
|
9e1b58d033 | ||
|
|
1acc8cd78c | ||
|
|
3140af63de | ||
|
|
829ebf59f7 | ||
|
|
4ce145bac2 | ||
|
|
6ef229f3d0 | ||
|
|
19b4fd520a | ||
|
|
70146e0b70 | ||
|
|
a804368806 | ||
|
|
3ec42fffaa | ||
|
|
95727335a7 | ||
|
|
79f9a3a5c2 | ||
|
|
7daebc308b | ||
|
|
50017cff36 | ||
|
|
f812c9e666 | ||
|
|
87a35af693 | ||
|
|
4c4a397f66 | ||
|
|
d720a7812a | ||
|
|
d2dec56cca | ||
|
|
ab2da7b975 | ||
|
|
8f69e4badd | ||
|
|
5bd00f24a2 | ||
|
|
ab9ee8d962 | ||
|
|
852e1586e7 | ||
|
|
23b388f3b8 | ||
|
|
22ae2c7124 | ||
|
|
5ad63d31d3 | ||
|
|
d55d4487ed | ||
|
|
4283d27da6 | ||
|
|
4576cf9f2c | ||
|
|
d1d5d38b32 | ||
|
|
e5ce98c874 | ||
|
|
96b7dbb1c5 | ||
|
|
9408b12bc7 | ||
|
|
4e85a4718f | ||
|
|
d1f1eb8e80 | ||
|
|
da364746c4 | ||
|
|
d672f68049 | ||
|
|
dcca768b6c | ||
|
|
ea69b4bead | ||
|
|
7b4188a376 | ||
|
|
252132430c | ||
|
|
7f9bc95c5c | ||
|
|
69fca82a86 | ||
|
|
9a30cac7b0 | ||
|
|
558bb37354 | ||
|
|
5b74852193 | ||
|
|
d18cf1ac37 | ||
|
|
96f55ff28e | ||
|
|
825f37d360 | ||
|
|
8eb27c5267 | ||
|
|
18d9dd6ff9 | ||
|
|
308521c632 | ||
|
|
86b2b5148d | ||
|
|
b9e0e4a6dc | ||
|
|
1b8849ead1 | ||
|
|
1fe635384f | ||
|
|
df16d66753 | ||
|
|
65e2c24928 | ||
|
|
c4b8621e2a | ||
|
|
88a9f8a97b | ||
|
|
fc91d59b99 | ||
|
|
aad4711056 | ||
|
|
c7c6d95334 | ||
|
|
84b4c66309 | ||
|
|
923d77072b | ||
|
|
758aa7f7f7 | ||
|
|
866a74fa29 | ||
|
|
36a51070b3 | ||
|
|
585b65e11d | ||
|
|
2c8fe2a481 | ||
|
|
1b67bad270 | ||
|
|
afe91c7cc0 | ||
|
|
bd1b7e8809 | ||
|
|
ae9b04d4d2 | ||
|
|
bd6184554a | ||
|
|
834d68a47e | ||
|
|
05dbe6818d | ||
|
|
80dfeb1293 | ||
|
|
bf64c091cc | ||
|
|
b4d7ada317 | ||
|
|
a07f7ac389 | ||
|
|
46b8f2a8a5 | ||
|
|
91ada70c7d | ||
|
|
b2cfc0ed03 | ||
|
|
8d44717588 | ||
|
|
f44e8b7659 | ||
|
|
07523219d1 | ||
|
|
7f76e2095d | ||
|
|
313fe3e0b7 | ||
|
|
c817b31dfc | ||
|
|
9e038f5218 | ||
|
|
9e479d38fe | ||
|
|
2593606f16 | ||
|
|
1b91cc8ac2 | ||
|
|
28607c4744 | ||
|
|
dce73f91ef | ||
|
|
07de6062ca | ||
|
|
c9997d4c17 | ||
|
|
322bf26db5 | ||
|
|
98acd68f06 | ||
|
|
733f990858 | ||
|
|
bebb00aa2e | ||
|
|
193a0fd710 | ||
|
|
3650a438df | ||
|
|
5bee73180d | ||
|
|
672dd96e7e | ||
|
|
62104b417a | ||
|
|
562ad524c4 | ||
|
|
ea498df78b | ||
|
|
1ce239103c | ||
|
|
81036943c2 | ||
|
|
21e51c3d38 | ||
|
|
e92947fc3b | ||
|
|
94d45f7320 | ||
|
|
d04305433f | ||
|
|
63cbf30dd7 | ||
|
|
96eb17a963 | ||
|
|
8f2c5b397c | ||
|
|
648848c816 | ||
|
|
58b9c28a0b | ||
|
|
c3d18dbbe8 | ||
|
|
1e6a0edcfb | ||
|
|
d56de80381 | ||
|
|
3fa100be0c | ||
|
|
df1169e06d | ||
|
|
0ae1597ecd | ||
|
|
d722be8896 | ||
|
|
9018e6fa34 | ||
|
|
807fd10d13 | ||
|
|
f979e16b95 | ||
|
|
955a559c21 | ||
|
|
e458aca3e3 | ||
|
|
692bbb00f1 | ||
|
|
260b545a54 | ||
|
|
3a43b7a4c2 | ||
|
|
c87adfeecc | ||
|
|
d7cc10fa00 | ||
|
|
14531fa258 | ||
|
|
1e5603dce2 | ||
|
|
c64d32e2c0 | ||
|
|
665e525f0a | ||
|
|
09a0522e2d | ||
|
|
e89b1538af | ||
|
|
a3a27f0049 | ||
|
|
a4408cfacc | ||
|
|
a3216a4550 | ||
|
|
2668ea4553 | ||
|
|
dd7e392626 | ||
|
|
80fc94c4db | ||
|
|
ffc59af345 | ||
|
|
03ad10dfc5 | ||
|
|
eb26019a52 | ||
|
|
69d0308f46 | ||
|
|
ba0dc33583 | ||
|
|
e0c0efcb2f | ||
|
|
e3b1810229 | ||
|
|
e81c87f288 | ||
|
|
234cb70b97 | ||
|
|
201e3a93eb | ||
|
|
27144ee37e | ||
|
|
2477439ecc | ||
|
|
ff66e918cf | ||
|
|
ee7dc39afa | ||
|
|
4c69f917e7 | ||
|
|
8d19678e39 | ||
|
|
bf42517077 | ||
|
|
35aa656677 | ||
|
|
0be440efc8 | ||
|
|
eefe65c042 | ||
|
|
a42a532929 | ||
|
|
3bb07db63f | ||
|
|
32850d4ff9 | ||
|
|
92178d2e77 | ||
|
|
d592b10c87 | ||
|
|
188a92d124 | ||
|
|
3aaf53442b | ||
|
|
01d4b6e1fc | ||
|
|
a2dfca0e37 | ||
|
|
b3f64c6efe |
2
.config/nextest.toml
Normal file
2
.config/nextest.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[profile.default]
|
||||||
|
fail-fast = false
|
||||||
26
.devcontainer/Dockerfile
Normal file
26
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM rust:1.74
|
||||||
|
|
||||||
|
ARG USERNAME=lldapdev
|
||||||
|
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
||||||
|
# See https://github.com/actions/checkout/issues/956.
|
||||||
|
ARG USER_UID=1001
|
||||||
|
ARG USER_GID=$USER_UID
|
||||||
|
|
||||||
|
# Create the user
|
||||||
|
RUN groupadd --gid $USER_GID $USERNAME \
|
||||||
|
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y sudo \
|
||||||
|
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
|
||||||
|
&& chmod 0440 /etc/sudoers.d/$USERNAME
|
||||||
|
|
||||||
|
RUN apt update && \
|
||||||
|
apt install -y --no-install-recommends libssl-dev musl-dev make perl curl gzip && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
|
||||||
|
&& rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
|
USER $USERNAME
|
||||||
|
ENV CARGO_HOME=/home/$USERNAME/.cargo
|
||||||
|
ENV SHELL=/bin/bash
|
||||||
8
.devcontainer/devcontainer.json
Normal file
8
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "LLDAP dev",
|
||||||
|
"build": { "dockerfile": "Dockerfile" },
|
||||||
|
"forwardPorts": [
|
||||||
|
3890,
|
||||||
|
17170
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
.git/*
|
.git/*
|
||||||
.github/*
|
.github/*
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
# Don't track cargo generated files
|
# Don't track cargo generated files
|
||||||
target/*
|
target/*
|
||||||
@@ -17,6 +18,7 @@ Dockerfile
|
|||||||
*.md
|
*.md
|
||||||
LICENSE
|
LICENSE
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
|
README.md
|
||||||
docs/*
|
docs/*
|
||||||
example_configs/*
|
example_configs/*
|
||||||
|
|
||||||
@@ -28,12 +30,24 @@ package.json
|
|||||||
# Pre-build binaries
|
# Pre-build binaries
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
# VSCode dirs
|
||||||
|
.vscode
|
||||||
|
.devcontainer
|
||||||
|
|
||||||
|
# Created databases
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
# Various config files that shouldn't be tracked
|
# Various config files that shouldn't be tracked
|
||||||
.env
|
.env
|
||||||
lldap_config.toml
|
lldap_config.toml
|
||||||
server_key
|
server_key
|
||||||
users.db*
|
|
||||||
screenshot.png
|
screenshot.png
|
||||||
recipe.json
|
recipe.json
|
||||||
|
lldap_config.toml
|
||||||
cert.pem
|
cert.pem
|
||||||
key.pem
|
key.pem
|
||||||
|
|||||||
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
example_configs/** linguist-documentation
|
||||||
|
docs/** linguist-documentation
|
||||||
|
*.md linguist-documentation
|
||||||
|
lldap_config.docker_template.toml linguist-documentation
|
||||||
|
|
||||||
|
schema.graphql linguist-generated
|
||||||
|
|
||||||
|
.github/** -linguist-detectable
|
||||||
|
.devcontainer/** -linguist-detectable
|
||||||
|
.config/** -linguist-detectable
|
||||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @nitnelave
|
||||||
5
.github/FUNDING.yml
vendored
Normal file
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [lldap]
|
||||||
|
|
||||||
|
custom: ['https://bmc.link/nitnelave']
|
||||||
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: "[BUG]"
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Logs**
|
||||||
|
If applicable, add logs to explain the problem.
|
||||||
|
LLDAP should be started in verbose mode (`LLDAP_VERBOSE=true` env variable, or `verbose = true` in the config). Include the logs in triple-backtick "```"
|
||||||
|
If integrating with another service, please add its configuration (paste it or screenshot it) as well as any useful logs or screenshots (showing the error, for instance).
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: "[FEATURE REQUEST]"
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Motivation**
|
||||||
|
Why do you want the feature? What problem do you have, what use cases would it enable?
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered. You can include workarounds that are currently possible.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
25
.github/ISSUE_TEMPLATE/integration-request.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/integration-request.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: Integration request
|
||||||
|
about: Request for integration with a service
|
||||||
|
title: "[INTEGRATION]"
|
||||||
|
labels: integration
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Checklist**
|
||||||
|
- [ ] Check if there is already an [example config](https://github.com/lldap/lldap/tree/main/example_configs) for it.
|
||||||
|
- [ ] Try to figure out the configuration values for the new service yourself.
|
||||||
|
- You can use other example configs for inspiration.
|
||||||
|
- If you're having trouble, you can ask on [Discord](https://discord.gg/h5PEdRMNyP) or create an issue.
|
||||||
|
- If you succeed, make sure to contribute an example configuration, or a configuration guide.
|
||||||
|
- If you hit a block because of an unimplemented feature, create an issue.
|
||||||
|
|
||||||
|
**Description of the service**
|
||||||
|
Quick summary of what the service is and how it's using LDAP. Link to the service's documentation on configuring LDAP.
|
||||||
|
|
||||||
|
**What you've tried**
|
||||||
|
A sample configuration that you've tried.
|
||||||
|
|
||||||
|
**What's not working**
|
||||||
|
Error logs, error screenshots, features that are not working, missing features.
|
||||||
13
.github/codecov.yml
vendored
13
.github/codecov.yml
vendored
@@ -1,12 +1,23 @@
|
|||||||
codecov:
|
codecov:
|
||||||
require_ci_to_pass: yes
|
require_ci_to_pass: yes
|
||||||
comment:
|
comment:
|
||||||
layout: "diff,flags"
|
layout: "header,diff,files"
|
||||||
require_changes: true
|
require_changes: true
|
||||||
require_base: true
|
require_base: true
|
||||||
require_head: true
|
require_head: true
|
||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
target: "75%"
|
||||||
|
threshold: "0.1%"
|
||||||
|
removed_code_behavior: adjust_base
|
||||||
|
github_checks:
|
||||||
|
annotations: true
|
||||||
ignore:
|
ignore:
|
||||||
- "app"
|
- "app"
|
||||||
- "docs"
|
- "docs"
|
||||||
- "example_configs"
|
- "example_configs"
|
||||||
- "migration-tool"
|
- "migration-tool"
|
||||||
|
- "scripts"
|
||||||
|
- "set-password"
|
||||||
|
|||||||
86
.github/workflows/Dockerfile.ci.alpine
vendored
86
.github/workflows/Dockerfile.ci.alpine
vendored
@@ -1,65 +1,6 @@
|
|||||||
FROM debian:bullseye AS lldap
|
FROM localhost:5000/lldap/lldap:alpine-base
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
||||||
ARG TARGETPLATFORM
|
ENV GOSU_VERSION 1.17
|
||||||
RUN apt update && apt install -y wget
|
|
||||||
WORKDIR /dim
|
|
||||||
COPY bin/ bin/
|
|
||||||
COPY web/ web/
|
|
||||||
|
|
||||||
RUN mkdir -p target/
|
|
||||||
RUN mkdir -p /lldap/app
|
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
|
||||||
mv bin/amd64-bin/lldap target/lldap && \
|
|
||||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
|
||||||
chmod +x target/lldap && \
|
|
||||||
chmod +x target/migration-tool && \
|
|
||||||
ls -la target/ . && \
|
|
||||||
pwd \
|
|
||||||
; fi
|
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
|
||||||
mv bin/aarch64-bin/lldap target/lldap && \
|
|
||||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
|
||||||
chmod +x target/lldap && \
|
|
||||||
chmod +x target/migration-tool && \
|
|
||||||
ls -la target/ . && \
|
|
||||||
pwd \
|
|
||||||
; fi
|
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
|
||||||
mv bin/armhf-bin/lldap target/lldap && \
|
|
||||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
|
||||||
chmod +x target/lldap && \
|
|
||||||
chmod +x target/migration-tool && \
|
|
||||||
ls -la target/ . && \
|
|
||||||
pwd \
|
|
||||||
; fi
|
|
||||||
|
|
||||||
# Web and App dir
|
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
|
||||||
COPY lldap_config.docker_template.toml /lldap/
|
|
||||||
COPY web/index_local.html web/index.html
|
|
||||||
RUN cp target/lldap /lldap/ && \
|
|
||||||
cp target/migration-tool /lldap/ && \
|
|
||||||
cp -R web/index.html \
|
|
||||||
web/pkg \
|
|
||||||
web/static \
|
|
||||||
/lldap/app/
|
|
||||||
|
|
||||||
WORKDIR /lldap
|
|
||||||
RUN set -x \
|
|
||||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
|
||||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
|
||||||
&& chmod a+r -R .
|
|
||||||
|
|
||||||
FROM alpine:3.16
|
|
||||||
WORKDIR /app
|
|
||||||
ENV UID=1000
|
|
||||||
ENV GID=1000
|
|
||||||
ENV USER=lldap
|
|
||||||
ENV GOSU_VERSION 1.14
|
|
||||||
# Fetch gosu from git
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
\
|
\
|
||||||
apk add --no-cache --virtual .gosu-deps \
|
apk add --no-cache --virtual .gosu-deps \
|
||||||
@@ -76,7 +17,7 @@ RUN set -eux; \
|
|||||||
export GNUPGHOME="$(mktemp -d)"; \
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||||
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
command -v gpgconf && gpgconf --kill all || :; \
|
gpgconf --kill all; \
|
||||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
\
|
\
|
||||||
# clean up fetch dependencies
|
# clean up fetch dependencies
|
||||||
@@ -86,21 +27,4 @@ RUN set -eux; \
|
|||||||
# verify that the binary works
|
# verify that the binary works
|
||||||
gosu --version; \
|
gosu --version; \
|
||||||
gosu nobody true
|
gosu nobody true
|
||||||
RUN apk add --no-cache tini ca-certificates bash && \
|
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
addgroup -g $GID $USER && \
|
|
||||||
adduser \
|
|
||||||
--disabled-password \
|
|
||||||
--gecos "" \
|
|
||||||
--home "$(pwd)" \
|
|
||||||
--ingroup "$USER" \
|
|
||||||
--no-create-home \
|
|
||||||
--uid "$UID" \
|
|
||||||
"$USER" && \
|
|
||||||
mkdir -p /data && \
|
|
||||||
chown $USER:$USER /data
|
|
||||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
|
|
||||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
|
|
||||||
VOLUME ["/data"]
|
|
||||||
WORKDIR /app
|
|
||||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
|
||||||
|
|||||||
85
.github/workflows/Dockerfile.ci.alpine-base
vendored
Normal file
85
.github/workflows/Dockerfile.ci.alpine-base
vendored
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
FROM debian:bullseye AS lldap
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
RUN apt update && apt install -y wget
|
||||||
|
WORKDIR /dim
|
||||||
|
COPY bin/ bin/
|
||||||
|
COPY web/ web/
|
||||||
|
|
||||||
|
RUN mkdir -p target/
|
||||||
|
RUN mkdir -p /lldap/app
|
||||||
|
|
||||||
|
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||||
|
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||||
|
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||||
|
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||||
|
chmod +x target/lldap && \
|
||||||
|
chmod +x target/lldap_migration_tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
|
ls -la target/ . && \
|
||||||
|
pwd \
|
||||||
|
; fi
|
||||||
|
|
||||||
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||||
|
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||||
|
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||||
|
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||||
|
chmod +x target/lldap && \
|
||||||
|
chmod +x target/lldap_migration_tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
|
ls -la target/ . && \
|
||||||
|
pwd \
|
||||||
|
; fi
|
||||||
|
|
||||||
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||||
|
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||||
|
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||||
|
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||||
|
chmod +x target/lldap && \
|
||||||
|
chmod +x target/lldap_migration_tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
|
ls -la target/ . && \
|
||||||
|
pwd \
|
||||||
|
; fi
|
||||||
|
|
||||||
|
# Web and App dir
|
||||||
|
COPY lldap_config.docker_template.toml /lldap/
|
||||||
|
COPY web/index_local.html web/index.html
|
||||||
|
RUN cp target/lldap /lldap/ && \
|
||||||
|
cp target/lldap_migration_tool /lldap/ && \
|
||||||
|
cp target/lldap_set_password /lldap/ && \
|
||||||
|
cp -R web/index.html \
|
||||||
|
web/pkg \
|
||||||
|
web/static \
|
||||||
|
/lldap/app/
|
||||||
|
|
||||||
|
WORKDIR /lldap
|
||||||
|
RUN set -x \
|
||||||
|
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||||
|
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||||
|
&& chmod a+r -R .
|
||||||
|
|
||||||
|
FROM alpine:3.19
|
||||||
|
WORKDIR /app
|
||||||
|
ENV UID=1000
|
||||||
|
ENV GID=1000
|
||||||
|
ENV USER=lldap
|
||||||
|
RUN apk add --no-cache tini ca-certificates bash tzdata jq curl jo && \
|
||||||
|
addgroup -g $GID $USER && \
|
||||||
|
adduser \
|
||||||
|
--disabled-password \
|
||||||
|
--gecos "" \
|
||||||
|
--home "$(pwd)" \
|
||||||
|
--ingroup "$USER" \
|
||||||
|
--no-create-home \
|
||||||
|
--uid "$UID" \
|
||||||
|
"$USER" && \
|
||||||
|
mkdir -p /data && \
|
||||||
|
chown $USER:$USER /data
|
||||||
|
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||||
|
VOLUME ["/data"]
|
||||||
|
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
WORKDIR /app
|
||||||
|
COPY scripts/bootstrap.sh ./
|
||||||
|
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||||
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
3
.github/workflows/Dockerfile.ci.alpine-rootless
vendored
Normal file
3
.github/workflows/Dockerfile.ci.alpine-rootless
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM localhost:5000/lldap/lldap:alpine-base
|
||||||
|
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
|
||||||
|
USER $USER
|
||||||
102
.github/workflows/Dockerfile.ci.debian
vendored
102
.github/workflows/Dockerfile.ci.debian
vendored
@@ -1,71 +1,31 @@
|
|||||||
FROM debian:bullseye AS lldap
|
FROM localhost:5000/lldap/lldap:debian-base
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
||||||
ARG TARGETPLATFORM
|
ENV GOSU_VERSION 1.17
|
||||||
RUN apt update && apt install -y wget
|
RUN set -eux; \
|
||||||
WORKDIR /dim
|
# save list of currently installed packages for later so we can clean up
|
||||||
COPY bin/ bin/
|
savedAptMark="$(apt-mark showmanual)"; \
|
||||||
COPY web/ web/
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates gnupg wget; \
|
||||||
RUN mkdir -p target/
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
RUN mkdir -p /lldap/app
|
\
|
||||||
|
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||||
mv bin/amd64-bin/lldap target/lldap && \
|
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
|
||||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
\
|
||||||
chmod +x target/lldap && \
|
# verify the signature
|
||||||
chmod +x target/migration-tool && \
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
ls -la target/ . && \
|
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||||
pwd \
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
; fi
|
gpgconf --kill all; \
|
||||||
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
\
|
||||||
mv bin/aarch64-bin/lldap target/lldap && \
|
# clean up fetch dependencies
|
||||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
apt-mark auto '.*' > /dev/null; \
|
||||||
chmod +x target/lldap && \
|
[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; \
|
||||||
chmod +x target/migration-tool && \
|
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
|
||||||
ls -la target/ . && \
|
\
|
||||||
pwd \
|
chmod +x /usr/local/bin/gosu; \
|
||||||
; fi
|
# verify that the binary works
|
||||||
|
gosu --version; \
|
||||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
gosu nobody true
|
||||||
mv bin/armhf-bin/lldap target/lldap && \
|
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
|
||||||
chmod +x target/lldap && \
|
|
||||||
chmod +x target/migration-tool && \
|
|
||||||
ls -la target/ . && \
|
|
||||||
pwd \
|
|
||||||
; fi
|
|
||||||
|
|
||||||
# Web and App dir
|
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
|
||||||
COPY lldap_config.docker_template.toml /lldap/
|
|
||||||
COPY web/index_local.html web/index.html
|
|
||||||
RUN cp target/lldap /lldap/ && \
|
|
||||||
cp target/migration-tool /lldap/ && \
|
|
||||||
cp -R web/index.html \
|
|
||||||
web/pkg \
|
|
||||||
web/static \
|
|
||||||
/lldap/app/
|
|
||||||
|
|
||||||
WORKDIR /lldap
|
|
||||||
RUN set -x \
|
|
||||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
|
||||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
|
||||||
&& chmod a+r -R .
|
|
||||||
|
|
||||||
FROM debian:bullseye-slim
|
|
||||||
ENV UID=1000
|
|
||||||
ENV GID=1000
|
|
||||||
ENV USER=lldap
|
|
||||||
RUN apt update && \
|
|
||||||
apt install -y --no-install-recommends tini openssl ca-certificates gosu && \
|
|
||||||
apt clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
|
||||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
|
||||||
mkdir -p /data && chown $USER:$USER /data
|
|
||||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
|
||||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
|
||||||
VOLUME ["/data"]
|
|
||||||
WORKDIR /app
|
|
||||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
|
||||||
|
|||||||
80
.github/workflows/Dockerfile.ci.debian-base
vendored
Normal file
80
.github/workflows/Dockerfile.ci.debian-base
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
FROM debian:bullseye AS lldap
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
RUN apt update && apt install -y wget
|
||||||
|
WORKDIR /dim
|
||||||
|
COPY bin/ bin/
|
||||||
|
COPY web/ web/
|
||||||
|
|
||||||
|
RUN mkdir -p target/
|
||||||
|
RUN mkdir -p /lldap/app
|
||||||
|
|
||||||
|
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||||
|
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||||
|
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||||
|
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||||
|
chmod +x target/lldap && \
|
||||||
|
chmod +x target/lldap_migration_tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
|
ls -la target/ . && \
|
||||||
|
pwd \
|
||||||
|
; fi
|
||||||
|
|
||||||
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||||
|
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||||
|
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||||
|
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||||
|
chmod +x target/lldap && \
|
||||||
|
chmod +x target/lldap_migration_tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
|
ls -la target/ . && \
|
||||||
|
pwd \
|
||||||
|
; fi
|
||||||
|
|
||||||
|
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||||
|
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||||
|
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||||
|
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||||
|
chmod +x target/lldap && \
|
||||||
|
chmod +x target/lldap_migration_tool && \
|
||||||
|
chmod +x target/lldap_set_password && \
|
||||||
|
ls -la target/ . && \
|
||||||
|
pwd \
|
||||||
|
; fi
|
||||||
|
|
||||||
|
# Web and App dir
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
COPY lldap_config.docker_template.toml /lldap/
|
||||||
|
COPY web/index_local.html web/index.html
|
||||||
|
RUN cp target/lldap /lldap/ && \
|
||||||
|
cp target/lldap_migration_tool /lldap/ && \
|
||||||
|
cp target/lldap_set_password /lldap/ && \
|
||||||
|
cp -R web/index.html \
|
||||||
|
web/pkg \
|
||||||
|
web/static \
|
||||||
|
/lldap/app/
|
||||||
|
|
||||||
|
WORKDIR /lldap
|
||||||
|
RUN set -x \
|
||||||
|
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||||
|
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||||
|
&& chmod a+r -R .
|
||||||
|
|
||||||
|
FROM debian:bullseye-slim
|
||||||
|
ENV UID=1000
|
||||||
|
ENV GID=1000
|
||||||
|
ENV USER=lldap
|
||||||
|
RUN apt update && \
|
||||||
|
apt install -y --no-install-recommends tini openssl ca-certificates tzdata jq curl jo && \
|
||||||
|
apt clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
|
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||||
|
mkdir -p /data && chown $USER:$USER /data
|
||||||
|
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||||
|
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
VOLUME ["/data"]
|
||||||
|
WORKDIR /app
|
||||||
|
COPY scripts/bootstrap.sh ./
|
||||||
|
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||||
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||||
3
.github/workflows/Dockerfile.ci.debian-rootless
vendored
Normal file
3
.github/workflows/Dockerfile.ci.debian-rootless
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM localhost:5000/lldap/lldap:debian-base
|
||||||
|
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
|
||||||
|
USER $USER
|
||||||
51
.github/workflows/Dockerfile.dev
vendored
51
.github/workflows/Dockerfile.dev
vendored
@@ -1,34 +1,41 @@
|
|||||||
FROM rust:1.62-slim-bullseye
|
# Keep tracking base image
|
||||||
|
FROM rust:1.81-slim-bookworm
|
||||||
|
|
||||||
# Set needed env path
|
# Set needed env path
|
||||||
ENV PATH="/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||||
|
|
||||||
### Install build deps x86_64
|
# Set building env
|
||||||
|
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||||
|
CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||||
|
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=armv7l-linux-musleabihf-gcc \
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc \
|
||||||
|
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
|
||||||
|
CC_armv7_unknown_linux_musleabihf=armv7l-linux-musleabihf-gcc \
|
||||||
|
CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc \
|
||||||
|
CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc
|
||||||
|
|
||||||
|
### Install Additional Build Tools
|
||||||
RUN apt update && \
|
RUN apt update && \
|
||||||
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools && \
|
apt install -y --no-install-recommends curl git wget make perl pkg-config tar jq gzip && \
|
||||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
|
||||||
apt update && \
|
|
||||||
apt install -y --no-install-recommends nodejs && \
|
|
||||||
apt clean && \
|
apt clean && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/*
|
||||||
npm install -g npm && \
|
|
||||||
npm install -g yarn && \
|
### Add musl-gcc aarch64, x86_64 and armv7l
|
||||||
npm install -g pnpm
|
|
||||||
|
|
||||||
### Install build deps aarch64 build
|
|
||||||
RUN dpkg --add-architecture arm64 && \
|
|
||||||
apt update && \
|
|
||||||
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross && \
|
|
||||||
apt clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
|
||||||
rustup target add aarch64-unknown-linux-gnu
|
|
||||||
|
|
||||||
### Add musl-gcc aarch64 and x86_64
|
|
||||||
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
||||||
tar zxf ./x86_64-linux-musl-cross.tgz -C /opt && \
|
tar zxf ./x86_64-linux-musl-cross.tgz -C /opt && \
|
||||||
wget -c https://musl.cc/aarch64-linux-musl-cross.tgz && \
|
wget -c https://musl.cc/aarch64-linux-musl-cross.tgz && \
|
||||||
tar zxf ./aarch64-linux-musl-cross.tgz -C /opt && \
|
tar zxf ./aarch64-linux-musl-cross.tgz -C /opt && \
|
||||||
|
wget -c http://musl.cc/armv7l-linux-musleabihf-cross.tgz && \
|
||||||
|
tar zxf ./armv7l-linux-musleabihf-cross.tgz -C /opt && \
|
||||||
rm ./x86_64-linux-musl-cross.tgz && \
|
rm ./x86_64-linux-musl-cross.tgz && \
|
||||||
rm ./aarch64-linux-musl-cross.tgz
|
rm ./aarch64-linux-musl-cross.tgz && \
|
||||||
|
rm ./armv7l-linux-musleabihf-cross.tgz
|
||||||
|
|
||||||
|
### Add musl target
|
||||||
|
RUN rustup target add x86_64-unknown-linux-musl && \
|
||||||
|
rustup target add aarch64-unknown-linux-musl && \
|
||||||
|
rustup target add armv7-unknown-linux-musleabihf && \
|
||||||
|
rustup target add x86_64-unknown-freebsd
|
||||||
|
|
||||||
|
|
||||||
CMD ["bash"]
|
CMD ["bash"]
|
||||||
|
|||||||
925
.github/workflows/docker-build-static.yml
vendored
925
.github/workflows/docker-build-static.yml
vendored
File diff suppressed because it is too large
Load Diff
29
.github/workflows/rust.yml
vendored
29
.github/workflows/rust.yml
vendored
@@ -13,7 +13,6 @@ jobs:
|
|||||||
pre_job:
|
pre_job:
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Map a step output to a job output
|
|
||||||
outputs:
|
outputs:
|
||||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||||
steps:
|
steps:
|
||||||
@@ -22,7 +21,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
concurrent_skipping: 'outdated_runs'
|
concurrent_skipping: 'outdated_runs'
|
||||||
skip_after_successful_duplicate: 'true'
|
skip_after_successful_duplicate: 'true'
|
||||||
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh"]'
|
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".dockerignore", ".gitignore", "lldap_config.docker_template.toml", "Dockerfile"]'
|
||||||
do_not_skip: '["workflow_dispatch", "schedule"]'
|
do_not_skip: '["workflow_dispatch", "schedule"]'
|
||||||
cancel_others: true
|
cancel_others: true
|
||||||
|
|
||||||
@@ -34,8 +33,8 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v3.1.0
|
uses: actions/checkout@v4.2.2
|
||||||
- uses: Swatinem/rust-cache@v1
|
- uses: Swatinem/rust-cache@v2
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose --workspace
|
run: cargo build --verbose --workspace
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
@@ -53,9 +52,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v3.1.0
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v1
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
- name: Run cargo clippy
|
- name: Run cargo clippy
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@v1
|
||||||
@@ -70,9 +69,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v3.1.0
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v1
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
- name: Run cargo fmt
|
- name: Run cargo fmt
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@v1
|
||||||
@@ -82,26 +81,30 @@ jobs:
|
|||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
name: Code coverage
|
name: Code coverage
|
||||||
needs: pre_job
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- test
|
||||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v3.1.0
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v1
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
- name: Generate code coverage for unit test
|
- name: Generate code coverage for unit test
|
||||||
run: cargo llvm-cov --workspace --no-report
|
run: cargo llvm-cov --workspace --no-report
|
||||||
- name: Aggregate reports
|
- name: Aggregate reports
|
||||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov (main)
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
files: lcov.info
|
files: lcov.info
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
|
codecov_yml_path: .github/codecov.yml
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ server_key
|
|||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
|
.vscode
|
||||||
.env
|
.env
|
||||||
recipe.json
|
recipe.json
|
||||||
lldap_config.toml
|
lldap_config.toml
|
||||||
|
|||||||
254
CHANGELOG.md
254
CHANGELOG.md
@@ -5,7 +5,259 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.6.1] 2024-11-22
|
||||||
|
|
||||||
|
Small release, mainly to fix a migration issue with Sqlite and Postgresql.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a link to a community terraform provider (#1035)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The opaque dependency now points to the official crate rather than a fork (#1040)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Migration of the DB schema from 7 to 8 is now automatic for sqlite, and fixed for postgres (#1045)
|
||||||
|
- The startup warning about `key_seed` applying instead of `key_file` now has instructions on how to silence it (#1032)
|
||||||
|
|
||||||
|
### New services
|
||||||
|
|
||||||
|
- OneDev
|
||||||
|
|
||||||
|
## [0.6.0] 2024-11-09
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- The endpoint `/auth/reset/step1` is now `POST` instead of `GET` (#704)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Custom attributes are now supported (#67) ! You can add new fields (string, integers, JPEG or dates) to users and query them. That unlocks many integrations with other services, and allows for a deeper/more customized integration. Special thanks to @pixelrazor and @bojidar-bg for their help with the UI.
|
||||||
|
- Custom object classes (for all users/groups) can now be added (#833)
|
||||||
|
- Barebones support for Paged Results Control (no paging, no respect for windows, but a correct response with all the results) (#698)
|
||||||
|
- A daily docker image is tagged and released. (#613)
|
||||||
|
- A bootstrap script allows reading the list of users/groups from a file and making sure the server contains exactly the same thing. (#654)
|
||||||
|
- Make it possible to serve lldap behind a sub-path in (#752)
|
||||||
|
- LLDAP can now be found on a custom package repository for opensuse, fedora, ubuntu, debian and centos ([Repository link](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap)). Thanks @Masgalor for setting it up and maintaining it.
|
||||||
|
- There's now an option to force reset the admin password (#748) optionally on every restart (#959)
|
||||||
|
- There's a rootless docker container (#755)
|
||||||
|
- entryDN is now supported (#780)
|
||||||
|
- Unknown LDAP controls are now detected and ignored (#787, #799)
|
||||||
|
- A community-developed CLI for scripting (#793)
|
||||||
|
- Added a way to print raw logs to debug long-running sessions (#992)
|
||||||
|
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The official docker repository is now `lldap/lldap`
|
||||||
|
- Removed password length limitation in lldap_set_password tool
|
||||||
|
- Group names and emails are now case insensitive, but keep their casing (#666)
|
||||||
|
- Better error messages (and exit code (#745)) when changing the private key (#778, #1008), using the wrong SMTP port (#970), using the wrong env variables (#972)
|
||||||
|
- Allow `member=` filters with plain user names (not full DNs) (#949)
|
||||||
|
- Correctly detect and refuse anonymous binds (#974)
|
||||||
|
- Clearer logging (#971, #981, #982)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Logging out applies globally, not just in the local browser. (#721)
|
||||||
|
- It's no longer possible to create the same user twice (#745)
|
||||||
|
- Fix wide substring filters (#738)
|
||||||
|
- Don't log the database password if provided in the connection URL (#735)
|
||||||
|
- Fix a panic when postgres uses a different collation (#821)
|
||||||
|
- The UI now defaults to the user ID for users with no display names (#843)
|
||||||
|
- Fix searching for users with more than one `memberOf` filter (#872)
|
||||||
|
- Fix compilation on Windows (#932) and Illumos (#964)
|
||||||
|
- The UI now correctly detects whether password resets are enabled. (#753)
|
||||||
|
- Fix a missing lowercasing of username when changing passwords through LDAP (#1012)
|
||||||
|
- Fix SQLite writers erroring when racing (#1021)
|
||||||
|
- LDAP sessions no longer buffer their logs until unbind, causing memory leaks (#1025)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Only expand attributes once per query, not per result (#687)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- When asked to send a password reset to an unknown email, sleep for 3 seconds and don't print the email in the error (#887)
|
||||||
|
|
||||||
|
### New services
|
||||||
|
|
||||||
|
Linux user accounts can now be managed by LLDAP, using PAM and nslcd.
|
||||||
|
|
||||||
|
- Apereo CAS server
|
||||||
|
- Carpal
|
||||||
|
- Gitlab
|
||||||
|
- Grocy
|
||||||
|
- Harbor
|
||||||
|
- Home Assistant
|
||||||
|
- Jenkins
|
||||||
|
- Kasm
|
||||||
|
- Maddy
|
||||||
|
- Mastodon
|
||||||
|
- Metabase
|
||||||
|
- MegaRAC-BMC
|
||||||
|
- Netbox
|
||||||
|
- OCIS
|
||||||
|
- Prosody
|
||||||
|
- Radicale
|
||||||
|
- SonarQube
|
||||||
|
- Traccar
|
||||||
|
- Zitadel
|
||||||
|
|
||||||
|
## [0.5.0] 2023-09-14
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- Emails and UUIDs are now enforced to be unique.
|
||||||
|
- If you have several users with the same email, you'll have to disambiguate
|
||||||
|
them. You can do that by either issuing SQL commands directly
|
||||||
|
(`UPDATE users SET email = 'x@x' WHERE user_id = 'bob';`), or by reverting
|
||||||
|
to a 0.4.x version of LLDAP and editing the user through the web UI.
|
||||||
|
An error will prevent LLDAP 0.5+ from starting otherwise.
|
||||||
|
- This was done to prevent account takeover for systems that allow to
|
||||||
|
login via email.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The server private key can be set as a seed from an env variable (#504).
|
||||||
|
- This is especially useful when you have multiple containers, they don't
|
||||||
|
need to share a writeable folder.
|
||||||
|
- Added support for changing the password through a plain LDAP Modify
|
||||||
|
operation (as opposed to an extended operation), to allow Jellyfin
|
||||||
|
to change password (#620).
|
||||||
|
- Allow creating a user with multiple objectClass (#612).
|
||||||
|
- Emails now have a message ID (#608).
|
||||||
|
- Added a warning for browsers that have WASM/JS disabled (#639).
|
||||||
|
- Added support for querying OUs in LDAP (#669).
|
||||||
|
- Added a button to clear the avatar in the UI (#358).
|
||||||
|
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Groups are now sorted by name in the web UI (#623).
|
||||||
|
- ARM build now uses musl (#584).
|
||||||
|
- Improved logging.
|
||||||
|
- Default admin user is only created if there are no admins (#563).
|
||||||
|
- That allows you to remove the default admin, making it harder to
|
||||||
|
bruteforce.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed URL parsing with a trailing slash in the password setting utility
|
||||||
|
(#597).
|
||||||
|
|
||||||
|
In addition to all that, there was significant progress towards #67,
|
||||||
|
user-defined attributes. That complex feature will unblock integration with many
|
||||||
|
systems, including PAM authentication.
|
||||||
|
|
||||||
|
### New services
|
||||||
|
|
||||||
|
- Ejabberd
|
||||||
|
- Ergo
|
||||||
|
- LibreNMS
|
||||||
|
- Mealie
|
||||||
|
- MinIO
|
||||||
|
- OpnSense
|
||||||
|
- PfSense
|
||||||
|
- PowerDnsAdmin
|
||||||
|
- Proxmox
|
||||||
|
- Squid
|
||||||
|
- Tandoor recipes
|
||||||
|
- TheLounge
|
||||||
|
- Zabbix-web
|
||||||
|
- Zulip
|
||||||
|
|
||||||
|
## [0.4.3] 2023-04-11
|
||||||
|
|
||||||
|
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
|
||||||
|
and on DockerHub (although we will keep publishing the images to
|
||||||
|
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
|
||||||
|
migrated, and the new docker images are available both on DockerHub and on the
|
||||||
|
GHCR under `lldap/lldap`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- EC private keys are not supported for LDAPS.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- SMTP user no longer has a default value (and instead defaults to unauthenticated).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- WASM payload is now delivered uncompressed to Safari due to a Safari bug.
|
||||||
|
- Password reset no longer redirects to login page.
|
||||||
|
- NextCloud config should add the "mail" attribute.
|
||||||
|
- GraphQL parameters are now urldecoded, to support special characters in usernames.
|
||||||
|
- Healthcheck correctly checks the server certificate.
|
||||||
|
|
||||||
|
### New services
|
||||||
|
|
||||||
|
- Home Assistant
|
||||||
|
- Shaarli
|
||||||
|
|
||||||
|
## [0.4.2] - 2023-03-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add support for MySQL/MariaDB/PostgreSQL, in addition to SQLite.
|
||||||
|
- Healthcheck command for docker setups.
|
||||||
|
- User creation through LDAP.
|
||||||
|
- IPv6 support.
|
||||||
|
- Dev container for VsCode.
|
||||||
|
- Add support for DN LDAP filters.
|
||||||
|
- Add support for SubString LDAP filters.
|
||||||
|
- Add support for LdapCompare operation.
|
||||||
|
- Add support for unencrypted/unauthenticated SMTP connection.
|
||||||
|
- Add a command to setup the database schema.
|
||||||
|
- Add a tool to set a user's password from the command line.
|
||||||
|
- Added consistent release artifacts.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Payload is now compressed, reducing the size to 700kb.
|
||||||
|
- entryUUID is returned in the default LDAP fields.
|
||||||
|
- Slightly improved support for LDAP browsing tools.
|
||||||
|
- Password reset can be identified by email (instead of just username).
|
||||||
|
- Various front-end improvements, and support for dark mode.
|
||||||
|
- Add content-type header to the password reset email, fixing rendering issues in some clients.
|
||||||
|
- Identify groups with "cn" instead of "uid" in memberOf field.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed dependency on nodejs/rollup.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Email is now using the async API.
|
||||||
|
- Fix handling of empty/null names (display, first, last).
|
||||||
|
- Obscured old password field when changing password.
|
||||||
|
- Respect user setting to disable password resets.
|
||||||
|
- Fix handling of "present" filters with unknown attributes.
|
||||||
|
- Fix handling of filters that could lead to an ambiguous SQL query.
|
||||||
|
|
||||||
|
### New services
|
||||||
|
|
||||||
|
- Authentik
|
||||||
|
- Dell iDRAC
|
||||||
|
- Dex
|
||||||
|
- Kanboard
|
||||||
|
- NextCloud + OIDC or Authelia
|
||||||
|
- Nexus
|
||||||
|
- SUSE Rancher
|
||||||
|
- VaultWarden
|
||||||
|
- WeKan
|
||||||
|
- WikiJS
|
||||||
|
- ZendTo
|
||||||
|
|
||||||
|
### Dependencies (highlights)
|
||||||
|
|
||||||
|
- Upgraded Yew to 0.19
|
||||||
|
- Upgraded actix to 0.13
|
||||||
|
- Upgraded clap to 4
|
||||||
|
- Switched from sea-query to sea-orm 0.11
|
||||||
|
|
||||||
## [0.4.1] - 2022-10-10
|
## [0.4.1] - 2022-10-10
|
||||||
|
|
||||||
|
|||||||
97
CONTRIBUTING.md
Normal file
97
CONTRIBUTING.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# How to contribute to LLDAP
|
||||||
|
|
||||||
|
## Did you find a bug?
|
||||||
|
|
||||||
|
- Make sure there isn't already an [issue](https://github.com/lldap/lldap/issues?q=is%3Aissue+is%3Aopen) for it.
|
||||||
|
- Check if the bug still happens with the `latest` docker image, or the `main` branch if you compile it yourself.
|
||||||
|
- [Create an issue](https://github.com/lldap/lldap/issues/new) on GitHub. What makes a great issue:
|
||||||
|
- A quick summary of the bug.
|
||||||
|
- Steps to reproduce.
|
||||||
|
- LLDAP _verbose_ logs when reproducing the bug. Verbose mode can be set through environment variables (`LLDAP_VERBOSE=true`) or in the config (`verbose = true`).
|
||||||
|
- What you expected to happen.
|
||||||
|
- What actually happened.
|
||||||
|
- Other notes (what you tried, why you think it's happening, ...).
|
||||||
|
|
||||||
|
## Are you requesting integration with a new service?
|
||||||
|
|
||||||
|
- Check if there is already an [example config](https://github.com/lldap/lldap/tree/main/example_configs) for it.
|
||||||
|
- Try to figure out the configuration values for the new service yourself.
|
||||||
|
- You can use other example configs for inspiration.
|
||||||
|
- If you're having trouble, you can ask on [Discord](https://discord.gg/h5PEdRMNyP)
|
||||||
|
- If you succeed, make sure to contribute an example configuration, or a configuration guide.
|
||||||
|
- If you hit a block because of an unimplemented feature, go to the next section.
|
||||||
|
|
||||||
|
## Are you asking for a new feature?
|
||||||
|
|
||||||
|
- Make sure there isn't already an [issue](https://github.com/lldap/lldap/issues?q=is%3Aissue+is%3Aopen) for it.
|
||||||
|
- [Create an issue](https://github.com/lldap/lldap/issues/new) on GitHub. What makes a great feature request:
|
||||||
|
- A quick summary of the feature.
|
||||||
|
- Motivation: what problem does the feature solve?
|
||||||
|
- Workarounds: what are the currently possible solutions to the problem, however bad?
|
||||||
|
|
||||||
|
## Do you want to work on a PR?
|
||||||
|
|
||||||
|
That's great! There are 2 main ways to contribute to the project: documentation and code.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
The simplest way to contribute is to submit a configuration guide for a new
|
||||||
|
service: it can be an example configuration file, or a markdown guide
|
||||||
|
explaining the steps necessary to configure the service.
|
||||||
|
|
||||||
|
We also have some
|
||||||
|
[documentation](https://github.com/lldap/lldap/tree/main/docs) with more
|
||||||
|
advanced guides (scripting, migrations, ...) you can contribute to.
|
||||||
|
|
||||||
|
### Code
|
||||||
|
|
||||||
|
If you don't know what to start with, check out the
|
||||||
|
[good first issues](https://github.com/lldap/lldap/labels/good%20first%20issue).
|
||||||
|
|
||||||
|
Otherwise, if you want to fix a specific bug or implement a feature, make sure
|
||||||
|
to start by creating an issue for it (if it doesn't already exist). There, we
|
||||||
|
can discuss whether it would be likely to be accepted and consider design
|
||||||
|
issues. That will save you from going down a wrong path, creating an entire PR
|
||||||
|
before getting told that it doesn't align with the project or the design is
|
||||||
|
flawed!
|
||||||
|
|
||||||
|
Once we agree on what to do in the issue, you can start working on the PR. A good quality PR has:
|
||||||
|
- A description of the change.
|
||||||
|
- The format we use for both commit titles and PRs is:
|
||||||
|
`tag: Do the thing`
|
||||||
|
The tag can be: server, app, docker, example_configs, ... It's a broad category.
|
||||||
|
The rest of the title should be an imperative sentence (see for instance [Commit Message
|
||||||
|
Guidelines](https://gist.github.com/robertpainsi/b632364184e70900af4ab688decf6f53)).
|
||||||
|
- The PR should refer to the issue it's addressing (e.g. "Fix #123").
|
||||||
|
- Explain the _why_ of the change.
|
||||||
|
- But also the _how_.
|
||||||
|
- Highlight any potential flaw or limitation.
|
||||||
|
- The code change should be as small as possible while solving the problem.
|
||||||
|
- Don't try to code-golf to change fewer characters, but keep logically separate changes in
|
||||||
|
different PRs.
|
||||||
|
- Add tests if possible.
|
||||||
|
- The tests should highlight the original issue in case of a bug.
|
||||||
|
- Ideally, we can apply the tests without the rest of the change and they would fail. With the
|
||||||
|
change, they pass.
|
||||||
|
- In some areas, there is no test infrastructure in place (e.g. for frontend changes). In that
|
||||||
|
case, do some manual testing and include the results (logs for backend changes, screenshot of a
|
||||||
|
successful service integration, screenshot of the frontend change).
|
||||||
|
- For backend changes, the tests should cover a significant portion of the new code paths, or
|
||||||
|
everything if possible. You can also add more tests to cover existing code.
|
||||||
|
- Of course, make sure all the existing tests pass. This will be checked anyway in the GitHub CI.
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
We use [GitHub Flow](https://docs.github.com/en/get-started/quickstart/github-flow):
|
||||||
|
- Fork the repository.
|
||||||
|
- (Optional) Create a new branch, or just use `main` in your fork.
|
||||||
|
- Make your change.
|
||||||
|
- Create a PR.
|
||||||
|
- Address the comments by adding more commits to your branch (or to `main`).
|
||||||
|
- The PR gets merged (the commits get squashed to a single one).
|
||||||
|
- (Optional) You can delete your branch/fork.
|
||||||
|
|
||||||
|
## Reminder
|
||||||
|
|
||||||
|
We're all volunteers, so be kind to each other! And since we're doing that in our free time, some
|
||||||
|
things can take a longer than expected.
|
||||||
4026
Cargo.lock
generated
4026
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
@@ -3,12 +3,19 @@ members = [
|
|||||||
"server",
|
"server",
|
||||||
"auth",
|
"auth",
|
||||||
"app",
|
"app",
|
||||||
"migration-tool"
|
"migration-tool",
|
||||||
|
"set-password",
|
||||||
]
|
]
|
||||||
|
|
||||||
default-members = ["server"]
|
default-members = ["server"]
|
||||||
|
|
||||||
# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged.
|
resolver = "2"
|
||||||
[patch.crates-io.ldap3_proto]
|
|
||||||
git = 'https://github.com/nitnelave/ldap3_server/'
|
[profile.release]
|
||||||
rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc'
|
lto = true
|
||||||
|
|
||||||
|
[profile.release.package.lldap_app]
|
||||||
|
opt-level = 's'
|
||||||
|
|
||||||
|
[patch.crates-io.lber]
|
||||||
|
git = 'https://github.com/inejge/ldap3/'
|
||||||
|
|||||||
48
Dockerfile
48
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Build image
|
# Build image
|
||||||
FROM rust:alpine3.14 AS chef
|
FROM rust:alpine3.16 AS chef
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
# Add user
|
# Add user
|
||||||
@@ -11,7 +11,7 @@ RUN set -x \
|
|||||||
--uid 10001 \
|
--uid 10001 \
|
||||||
app \
|
app \
|
||||||
# Install required packages
|
# Install required packages
|
||||||
&& apk add npm openssl-dev musl-dev make perl curl
|
&& apk add openssl-dev musl-dev make perl curl gzip
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -19,7 +19,6 @@ WORKDIR /app
|
|||||||
RUN set -x \
|
RUN set -x \
|
||||||
# Install build tools
|
# Install build tools
|
||||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||||
&& npm install rollup \
|
|
||||||
&& rustup target add wasm32-unknown-unknown
|
&& rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
# Prepare the dependency list.
|
# Prepare the dependency list.
|
||||||
@@ -32,27 +31,59 @@ FROM chef AS builder
|
|||||||
COPY --from=planner /tmp/recipe.json recipe.json
|
COPY --from=planner /tmp/recipe.json recipe.json
|
||||||
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||||
&& cargo chef cook --release -p lldap \
|
&& cargo chef cook --release -p lldap \
|
||||||
&& cargo chef cook --release -p migration-tool
|
&& cargo chef cook --release -p lldap_migration_tool \
|
||||||
|
&& cargo chef cook --release -p lldap_set_password
|
||||||
|
|
||||||
# Copy the source and build the app and server.
|
# Copy the source and build the app and server.
|
||||||
COPY --chown=app:app . .
|
COPY --chown=app:app . .
|
||||||
RUN cargo build --release -p lldap -p migration-tool \
|
RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password \
|
||||||
# Build the frontend.
|
# Build the frontend.
|
||||||
&& ./app/build.sh
|
&& ./app/build.sh
|
||||||
|
|
||||||
# Final image
|
# Final image
|
||||||
FROM alpine:3.14
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
ENV GOSU_VERSION 1.14
|
||||||
|
# Fetch gosu from git
|
||||||
|
RUN set -eux; \
|
||||||
|
\
|
||||||
|
apk add --no-cache --virtual .gosu-deps \
|
||||||
|
ca-certificates \
|
||||||
|
dpkg \
|
||||||
|
gnupg \
|
||||||
|
; \
|
||||||
|
\
|
||||||
|
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||||
|
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||||
|
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
|
||||||
|
\
|
||||||
|
# verify the signature
|
||||||
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
|
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||||
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
|
command -v gpgconf && gpgconf --kill all || :; \
|
||||||
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
|
\
|
||||||
|
# clean up fetch dependencies
|
||||||
|
apk del --no-network .gosu-deps; \
|
||||||
|
\
|
||||||
|
chmod +x /usr/local/bin/gosu; \
|
||||||
|
# verify that the binary works
|
||||||
|
gosu --version; \
|
||||||
|
gosu nobody true
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/app/index_local.html app/index.html
|
COPY --from=builder /app/app/index_local.html app/index.html
|
||||||
COPY --from=builder /app/app/static app/static
|
COPY --from=builder /app/app/static app/static
|
||||||
COPY --from=builder /app/app/pkg app/pkg
|
COPY --from=builder /app/app/pkg app/pkg
|
||||||
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool ./
|
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||||
|
COPY scripts/bootstrap.sh ./
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
&& apk add --no-cache bash \
|
&& apk add --no-cache bash tzdata \
|
||||||
&& for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done \
|
&& for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||||
&& for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
&& for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||||
&& chmod a+r -R .
|
&& chmod a+r -R .
|
||||||
@@ -64,3 +95,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
|||||||
|
|
||||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
|||||||
666
README.md
666
README.md
@@ -5,14 +5,15 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
<a href="https://github.com/lldap/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||||
<img
|
<img
|
||||||
src="https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg"
|
src="https://github.com/lldap/lldap/actions/workflows/rust.yml/badge.svg"
|
||||||
alt="Build"/>
|
alt="Build"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/h5PEdRMNyP">
|
<a href="https://discord.gg/h5PEdRMNyP">
|
||||||
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
|
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
|
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
|
||||||
<img
|
<img
|
||||||
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
|
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
|
||||||
@@ -23,25 +24,40 @@
|
|||||||
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
|
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
|
||||||
alt="Unsafe forbidden"/>
|
alt="Unsafe forbidden"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://app.codecov.io/gh/nitnelave/lldap">
|
<a href="https://app.codecov.io/gh/lldap/lldap">
|
||||||
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/nitnelave/lldap" />
|
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/lldap/lldap" />
|
||||||
|
</a>
|
||||||
|
<br/>
|
||||||
|
<a href="https://www.buymeacoffee.com/nitnelave" target="_blank">
|
||||||
|
<img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" >
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
- [About](#About)
|
- [About](#about)
|
||||||
- [Installation](#Installation)
|
- [Installation](#installation)
|
||||||
- [With Docker](#With-Docker)
|
- [With Docker](#with-docker)
|
||||||
- [From source](#From-source)
|
- [With Kubernetes](#with-kubernetes)
|
||||||
- [Cross-compilation](#Cross-compilation)
|
- [From a package repository](#from-a-package-repository)
|
||||||
- [Client configuration](#Client-configuration)
|
- [With FreeBSD](#with-freebsd)
|
||||||
- [Compatible services](#compatible-services)
|
- [From source](#from-source)
|
||||||
- [General configuration guide](#general-configuration-guide)
|
- [Backend](#backend)
|
||||||
- [Sample client configurations](#Sample-client-configurations)
|
- [Frontend](#frontend)
|
||||||
- [Comparisons with other services](#Comparisons-with-other-services)
|
- [Cross-compilation](#cross-compilation)
|
||||||
- [vs OpenLDAP](#vs-openldap)
|
- [Usage](#usage)
|
||||||
- [vs FreeIPA](#vs-freeipa)
|
- [Recommended architecture](#recommended-architecture)
|
||||||
- [I can't log in!](#i-cant-log-in)
|
- [Client configuration](#client-configuration)
|
||||||
- [Contributions](#Contributions)
|
- [Compatible services](#compatible-services)
|
||||||
|
- [General configuration guide](#general-configuration-guide)
|
||||||
|
- [Integration with OS's](#integration-with-oss)
|
||||||
|
- [Sample client configurations](#sample-client-configurations)
|
||||||
|
- [Incompatible services](#incompatible-services)
|
||||||
|
- [Migrating from SQLite](#migrating-from-sqlite)
|
||||||
|
- [Comparisons with other services](#comparisons-with-other-services)
|
||||||
|
- [vs OpenLDAP](#vs-openldap)
|
||||||
|
- [vs FreeIPA](#vs-freeipa)
|
||||||
|
- [vs Kanidm](#vs-kanidm)
|
||||||
|
- [I can't log in!](#i-cant-log-in)
|
||||||
|
- [Contributions](#contributions)
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
@@ -51,7 +67,7 @@ many backends, from KeyCloak to Authelia to Nextcloud and
|
|||||||
[more](#compatible-services)!
|
[more](#compatible-services)!
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
|
src="https://raw.githubusercontent.com/lldap/lldap/master/screenshot.png"
|
||||||
alt="Screenshot of the user list page"
|
alt="Screenshot of the user list page"
|
||||||
width="50%"
|
width="50%"
|
||||||
align="right"
|
align="right"
|
||||||
@@ -62,10 +78,11 @@ edit their own details or reset their password by email.
|
|||||||
|
|
||||||
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
||||||
check out OpenLDAP. This server is a user management system that is:
|
check out OpenLDAP. This server is a user management system that is:
|
||||||
* simple to setup (no messing around with `slapd`),
|
|
||||||
* simple to manage (friendly web UI),
|
- simple to setup (no messing around with `slapd`),
|
||||||
* low resources,
|
- simple to manage (friendly web UI),
|
||||||
* opinionated with basic defaults so you don't have to understand the
|
- low resources,
|
||||||
|
- opinionated with basic defaults so you don't have to understand the
|
||||||
subtleties of LDAP.
|
subtleties of LDAP.
|
||||||
|
|
||||||
It mostly targets self-hosting servers, with open-source components like
|
It mostly targets self-hosting servers, with open-source components like
|
||||||
@@ -76,13 +93,17 @@ 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.
|
||||||
|
|
||||||
|
By default, the data is stored in SQLite, but you can swap the backend with
|
||||||
|
MySQL/MariaDB or PostgreSQL.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### With Docker
|
### With Docker
|
||||||
|
|
||||||
The image is available at `nitnelave/lldap`. You should persist the `/data`
|
The image is available at `lldap/lldap`. You should persist the `/data`
|
||||||
folder, which contains your configuration, the database and the private key
|
folder, which contains your configuration and the SQLite database (you can
|
||||||
file.
|
remove this step if you use a different DB and configure with environment
|
||||||
|
variables only).
|
||||||
|
|
||||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
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
|
||||||
@@ -90,18 +111,25 @@ Configure the server by copying the `lldap_config.docker_template.toml` to
|
|||||||
Environment variables should be prefixed with `LLDAP_` to override the
|
Environment variables should be prefixed with `LLDAP_` to override the
|
||||||
configuration.
|
configuration.
|
||||||
|
|
||||||
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use default one. The default admin password is `password`, you can change the password later using the web interface.
|
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use
|
||||||
|
default one. The default admin password is `password`, you can change the
|
||||||
|
password later using the web interface.
|
||||||
|
|
||||||
Secrets can also be set through a file. The filename should be specified by the
|
Secrets can also be set through a file. The filename should be specified by the
|
||||||
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_LDAP_USER_PASS_FILE`, and the file
|
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_KEY_SEED_FILE`, and the file
|
||||||
contents are loaded into the respective configuration parameters. Note that
|
contents are loaded into the respective configuration parameters. Note that
|
||||||
`_FILE` variables take precedence.
|
`_FILE` variables take precedence.
|
||||||
|
|
||||||
Example for docker compose for `:stable` tag:
|
Example for docker compose:
|
||||||
* When defined with `user: ##:##` , ensure `/data` directory had permission for the defined user, else `1000:1000` used.
|
|
||||||
|
- You can use either the `:latest` tag image or `:stable` as used in this example.
|
||||||
|
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
|
||||||
|
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
|
||||||
|
- If no `TZ` is set, default `UTC` timezone will be used.
|
||||||
|
- You can generate the secrets by running `./generate_secrets.sh`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3'
|
version: "3"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
lldap_data:
|
lldap_data:
|
||||||
@@ -109,41 +137,12 @@ volumes:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
lldap:
|
lldap:
|
||||||
image: nitnelave/lldap:stable
|
image: lldap/lldap:stable
|
||||||
# Change this to the user:group you want.
|
|
||||||
user: "33:33"
|
|
||||||
ports:
|
ports:
|
||||||
# For LDAP
|
# For LDAP, not recommended to expose, see Usage section.
|
||||||
- "3890:3890"
|
#- "3890:3890"
|
||||||
# For the web front-end
|
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
|
||||||
- "17170:17170"
|
#- "6360:6360"
|
||||||
volumes:
|
|
||||||
- "lldap_data:/data"
|
|
||||||
# Alternatively, you can mount a local folder
|
|
||||||
# - "./lldap_data:/data"
|
|
||||||
environment:
|
|
||||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
|
||||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
|
||||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
|
||||||
```
|
|
||||||
|
|
||||||
Example for docker compose for `:latest` tag:
|
|
||||||
* `:latest` tag image contain recent pushed codes or feature test, breaks is expected.
|
|
||||||
* If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3'
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
lldap_data:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
services:
|
|
||||||
lldap:
|
|
||||||
image: nitnelave/lldap:latest
|
|
||||||
ports:
|
|
||||||
# For LDAP
|
|
||||||
- "3890:3890"
|
|
||||||
# For the web front-end
|
# For the web front-end
|
||||||
- "17170:17170"
|
- "17170:17170"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -153,38 +152,304 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- UID=####
|
- UID=####
|
||||||
- GID=####
|
- GID=####
|
||||||
|
- TZ=####/####
|
||||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
||||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
- LLDAP_LDAP_USER_PASS=adminPas$word
|
||||||
|
# If using LDAPS, set enabled true and configure cert and key path
|
||||||
|
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||||
|
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
||||||
|
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
|
||||||
|
# You can also set a different database:
|
||||||
|
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||||
|
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||||
|
# If using SMTP, set the following variables
|
||||||
|
# - LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET=true
|
||||||
|
# - LLDAP_SMTP_OPTIONS__SERVER=smtp.example.com
|
||||||
|
# - LLDAP_SMTP_OPTIONS__PORT=465 # Check your smtp providor's documentation for this setting
|
||||||
|
# - LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION=TLS # How the connection is encrypted, either "NONE" (no encryption, port 25), "TLS" (sometimes called SSL, port 465) or "STARTTLS" (sometimes called TLS, port 587).
|
||||||
|
# - LLDAP_SMTP_OPTIONS__USER=no-reply@example.com # The SMTP user, usually your email address
|
||||||
|
# - LLDAP_SMTP_OPTIONS__PASSWORD=PasswordGoesHere # The SMTP password
|
||||||
|
# - LLDAP_SMTP_OPTIONS__FROM=no-reply <no-reply@example.com> # The header field, optional: how the sender appears in the email. The first is a free-form name, followed by an email between <>.
|
||||||
|
# - LLDAP_SMTP_OPTIONS__TO=admin <admin@example.com> # Same for reply-to, optional.
|
||||||
```
|
```
|
||||||
|
|
||||||
Then the service will listen on two ports, one for LDAP and one for the web
|
Then the service will listen on two ports, one for LDAP and one for the web
|
||||||
front-end.
|
front-end.
|
||||||
|
|
||||||
|
### With Kubernetes
|
||||||
|
|
||||||
|
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
||||||
|
|
||||||
|
You can bootstrap your lldap instance (users, groups)
|
||||||
|
using [bootstrap.sh](example_configs/bootstrap/bootstrap.md#kubernetes-job).
|
||||||
|
It can be run by Argo CD for managing users in git-opt way, or as a one-shot job.
|
||||||
|
|
||||||
|
### From a package repository
|
||||||
|
|
||||||
|
**Do not open issues in this repository for problems with third-party
|
||||||
|
pre-built packages. Report issues downstream.**
|
||||||
|
|
||||||
|
Depending on the distribution you use, it might be possible to install lldap
|
||||||
|
from a package repository, officially supported by the distribution or
|
||||||
|
community contributed.
|
||||||
|
|
||||||
|
Each package offers a [systemd service](https://wiki.archlinux.org/title/systemd#Using_units) `lldap.service` to (auto-)start and stop lldap.<br>
|
||||||
|
When using the distributed packages, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Arch Linux</b></summary>
|
||||||
|
<br>
|
||||||
|
Arch Linux offers unofficial support through the <a href="https://wiki.archlinux.org/title/Arch_User_Repository">Arch User Repository (AUR)</a>.<br>
|
||||||
|
The package descriptions can be used <a href="https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started">to create and install packages</a>.<br><br>
|
||||||
|
Support: <a href="https://github.com/lldap/lldap/discussions/1044">Discussions</a><br>
|
||||||
|
Package repository: <a href="https://aur.archlinux.org/packages">Arch User Repository</a><br><br>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Package name</td>
|
||||||
|
<td>Maintainer</td>
|
||||||
|
<td>Description</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://aur.archlinux.org/packages/lldap">lldap</a></td>
|
||||||
|
<td><a href="https://github.com/Zepmann">@Zepmann</a></td>
|
||||||
|
<td>Builds the latest stable version.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://aur.archlinux.org/packages/lldap-bin">lldap-bin</a></td>
|
||||||
|
<td><a href="https://github.com/Zepmann">@Zepmann</a></td>
|
||||||
|
<td>Uses the latest pre-compiled binaries from the <a href="https://github.com/lldap/lldap/releases">releases in this repository</a>.<br>
|
||||||
|
This package is recommended if you want to run LLDAP on a system with limited resources.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="https://aur.archlinux.org/packages/lldap-git">lldap-git</a></td>
|
||||||
|
<td></td>
|
||||||
|
<td>Builds the latest main branch code.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
LLDAP configuration file: /etc/lldap.toml<br>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><b>Debian</b></summary>
|
||||||
|
<br>
|
||||||
|
Unofficial Debian support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||||
|
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||||
|
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||||
|
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Available packages:</td>
|
||||||
|
<td>lldap</td>
|
||||||
|
<td>Light LDAP server for authentication.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-extras</td>
|
||||||
|
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-migration-tool</td>
|
||||||
|
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-set-password</td>
|
||||||
|
<td>CLI tool to set a user password in LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-cli</td>
|
||||||
|
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><b>CentOS</b></summary>
|
||||||
|
<br>
|
||||||
|
Unofficial CentOS support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||||
|
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||||
|
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||||
|
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Available packages:</td>
|
||||||
|
<td>lldap</td>
|
||||||
|
<td>Light LDAP server for authentication.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-extras</td>
|
||||||
|
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-migration-tool</td>
|
||||||
|
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-set-password</td>
|
||||||
|
<td>CLI tool to set a user password in LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-cli</td>
|
||||||
|
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><b>Fedora</b></summary>
|
||||||
|
<br>
|
||||||
|
Unofficial Fedora support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||||
|
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||||
|
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||||
|
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Available packages:</td>
|
||||||
|
<td>lldap</td>
|
||||||
|
<td>Light LDAP server for authentication.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-extras</td>
|
||||||
|
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-migration-tool</td>
|
||||||
|
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-set-password</td>
|
||||||
|
<td>CLI tool to set a user password in LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-cli</td>
|
||||||
|
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><b>OpenSUSE</b></summary>
|
||||||
|
<br>
|
||||||
|
Unofficial OpenSUSE support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||||
|
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||||
|
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||||
|
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Available packages:</td>
|
||||||
|
<td>lldap</td>
|
||||||
|
<td>Light LDAP server for authentication.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-extras</td>
|
||||||
|
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-migration-tool</td>
|
||||||
|
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-set-password</td>
|
||||||
|
<td>CLI tool to set a user password in LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-cli</td>
|
||||||
|
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary><b>Ubuntu</b></summary>
|
||||||
|
<br>
|
||||||
|
Unofficial Ubuntu support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
|
||||||
|
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
|
||||||
|
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||||
|
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Available packages:</td>
|
||||||
|
<td>lldap</td>
|
||||||
|
<td>Light LDAP server for authentication.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-extras</td>
|
||||||
|
<td>Meta-Package for LLDAP and its tools and extensions.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-migration-tool</td>
|
||||||
|
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-set-password</td>
|
||||||
|
<td>CLI tool to set a user password in LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>lldap-cli</td>
|
||||||
|
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### With FreeBSD
|
||||||
|
|
||||||
|
You can also install it as a rc.d service in FreeBSD, see
|
||||||
|
[FreeBSD-install.md](example_configs/freebsd/freebsd-install.md).
|
||||||
|
|
||||||
|
The rc.d script file
|
||||||
|
[rc.d_lldap](example_configs/freebsd/rc.d_lldap).
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
|
||||||
To compile the project, you'll need:
|
To compile the project, you'll need:
|
||||||
|
|
||||||
* npm, curl: `sudo apt install curl npm`
|
- curl and gzip: `sudo apt install curl gzip`
|
||||||
* Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
- Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
||||||
|
|
||||||
Then you can compile the server (and the migration tool if you want):
|
Then you can compile the server (and the migration tool if you want):
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
cargo build --release -p lldap -p migration-tool
|
cargo build --release -p lldap -p lldap_migration_tool
|
||||||
```
|
```
|
||||||
|
|
||||||
The resulting binaries will be in `./target/release/`. Alternatively, you can
|
The resulting binaries will be in `./target/release/`. Alternatively, you can
|
||||||
just run `cargo run -- run` to run the server.
|
just run `cargo run -- run` to run the server.
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
|
||||||
To bring up the server, you'll need to compile the frontend. In addition to
|
To bring up the server, you'll need to compile the frontend. In addition to
|
||||||
cargo, you'll need:
|
`cargo`, you'll need WASM-pack, which can be installed by running `cargo install wasm-pack`.
|
||||||
|
|
||||||
* WASM-pack: `cargo install wasm-pack`
|
Then you can build the frontend files with
|
||||||
* rollup.js: `npm install rollup`
|
|
||||||
|
|
||||||
Then you can build the frontend files with `./app/build.sh` (you'll need to run
|
```shell
|
||||||
this after every front-end change to update the WASM package served).
|
./app/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
(you'll need to run this after every front-end change to update the WASM
|
||||||
|
package served).
|
||||||
|
|
||||||
The default config is in `src/infra/configuration.rs`, but you can override it
|
The default config is in `src/infra/configuration.rs`, but you can override it
|
||||||
by creating an `lldap_config.toml`, setting environment variables or passing
|
by creating an `lldap_config.toml`, setting environment variables or passing
|
||||||
@@ -216,6 +481,53 @@ You can then get the compiled server binary in
|
|||||||
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||||
files in an `app` folder next to the binary).
|
files in an `app` folder next to the binary).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The simplest way to use LLDAP is through the web front-end. There you can
|
||||||
|
create users, set passwords, add them to groups and so on. Users can also
|
||||||
|
connect to the web UI and change their information, or request a password reset
|
||||||
|
link (if you configured the SMTP client).
|
||||||
|
|
||||||
|
You can create and manage custom attributes through the Web UI, or through the
|
||||||
|
community-contributed CLI frontend (
|
||||||
|
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli)). This is necessary
|
||||||
|
for some service integrations.
|
||||||
|
|
||||||
|
The [bootstrap.sh](scripts/bootstrap.sh) script can enforce a list of
|
||||||
|
users/groups/attributes from a given file, reflecting it on the server.
|
||||||
|
|
||||||
|
To manage the user, group and membership lifecycle in an infrastructure-as-code
|
||||||
|
scenario you can use the unofficial [LLDAP terraform provider in the terraform registry](https://registry.terraform.io/providers/tasansga/lldap/latest).
|
||||||
|
|
||||||
|
LLDAP is also very scriptable, through its GraphQL API. See the
|
||||||
|
[Scripting](docs/scripting.md) docs for more info.
|
||||||
|
|
||||||
|
### Recommended architecture
|
||||||
|
|
||||||
|
If you are using containers, a sample architecture could look like this:
|
||||||
|
|
||||||
|
- A reverse proxy (e.g. nginx or Traefik)
|
||||||
|
- An authentication service (e.g. Authelia, Authentik or KeyCloak) connected to
|
||||||
|
LLDAP to provide authentication for non-authenticated services, or to provide
|
||||||
|
SSO with compatible ones.
|
||||||
|
- The LLDAP service, with the web port exposed to Traefik.
|
||||||
|
- The LDAP port doesn't need to be exposed, since only the other containers
|
||||||
|
will access it.
|
||||||
|
- You can also set up LDAPS if you want to expose the LDAP port to the
|
||||||
|
internet (not recommended) or for an extra layer of security in the
|
||||||
|
inter-container communication (though it's very much optional).
|
||||||
|
- The default LLDAP container starts up as root to fix up some files'
|
||||||
|
permissions before downgrading the privilege to the given user. However,
|
||||||
|
you can (should?) use the `*-rootless` version of the images to be able to
|
||||||
|
start directly as that user, once you got the permissions right. Just don't
|
||||||
|
forget to change from the `UID/GID` env vars to the `uid` docker-compose
|
||||||
|
field.
|
||||||
|
- Any other service that needs to connect to LLDAP for authentication (e.g.
|
||||||
|
NextCloud) can be added to a shared network with LLDAP. The finest
|
||||||
|
granularity is a network for each pair of LLDAP-service, but there are often
|
||||||
|
coarser granularities that make sense (e.g. a network for the \*arr stack and
|
||||||
|
LLDAP).
|
||||||
|
|
||||||
## Client configuration
|
## Client configuration
|
||||||
|
|
||||||
### Compatible services
|
### Compatible services
|
||||||
@@ -229,14 +541,15 @@ the config).
|
|||||||
### General configuration guide
|
### General configuration guide
|
||||||
|
|
||||||
To configure the services that will talk to LLDAP, here are the values:
|
To configure the services that will talk to LLDAP, here are the values:
|
||||||
- The LDAP user DN is from the configuration. By default,
|
|
||||||
`cn=admin,ou=people,dc=example,dc=com`.
|
- The LDAP user DN is from the configuration. By default,
|
||||||
- The LDAP password is from the configuration (same as to log in to the web
|
`cn=admin,ou=people,dc=example,dc=com`.
|
||||||
UI).
|
- The LDAP password is from the configuration (same as to log in to the web
|
||||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
UI).
|
||||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||||
|
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||||
|
|
||||||
Testing group membership through `memberOf` is supported, so you can have a
|
Testing group membership through `memberOf` is supported, so you can have a
|
||||||
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||||
@@ -246,58 +559,136 @@ admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
|||||||
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||||
administration access to many services.
|
administration access to many services.
|
||||||
|
|
||||||
|
### Integration with OS's
|
||||||
|
|
||||||
|
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
|
||||||
|
configuration guide](example_configs/pam/README.md).
|
||||||
|
|
||||||
|
Integration with Windows (e.g. Samba) is WIP.
|
||||||
|
|
||||||
### Sample client configurations
|
### Sample client configurations
|
||||||
|
|
||||||
Some specific clients have been tested to work and come with sample
|
Some specific clients have been tested to work and come with sample
|
||||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||||
folder for help with:
|
folder for help with:
|
||||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
|
||||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||||
- [Authelia](example_configs/authelia_config.yml)
|
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||||
- [Bookstack](example_configs/bookstack.env.example)
|
- [Apereo CAS Server](example_configs/apereo_cas_server.md)
|
||||||
- [Calibre-Web](example_configs/calibre_web.md)
|
- [Authelia](example_configs/authelia_config.yml)
|
||||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
- [Authentik](example_configs/authentik.md)
|
||||||
- [Dolibarr](example_configs/dolibarr.md)
|
- [Bookstack](example_configs/bookstack.env.example)
|
||||||
- [Emby](example_configs/emby.md)
|
- [Calibre-Web](example_configs/calibre_web.md)
|
||||||
- [Gitea](example_configs/gitea.md)
|
- [Carpal](example_configs/carpal.md)
|
||||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
- [Dex](example_configs/dex_config.yml)
|
||||||
- [Jellyfin](example_configs/jellyfin.md)
|
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
- [Dolibarr](example_configs/dolibarr.md)
|
||||||
- [KeyCloak](example_configs/keycloak.md)
|
- [Ejabberd](example_configs/ejabberd.md)
|
||||||
- [Matrix](example_configs/matrix_synapse.yml)
|
- [Emby](example_configs/emby.md)
|
||||||
- [Nextcloud](example_configs/nextcloud.md)
|
- [Ergo IRCd](example_configs/ergo.md)
|
||||||
- [Organizr](example_configs/Organizr.md)
|
- [Gitea](example_configs/gitea.md)
|
||||||
- [Portainer](example_configs/portainer.md)
|
- [GitLab](example_configs/gitlab.md)
|
||||||
- [Seafile](example_configs/seafile.md)
|
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||||
- [Syncthing](example_configs/syncthing.md)
|
- [Grocy](example_configs/grocy.md)
|
||||||
- [WG Portal](example_configs/wg_portal.env.example)
|
- [Harbor](example_configs/harbor.md)
|
||||||
- [XBackBone](example_configs/xbackbone_config.php)
|
- [HashiCorp Vault](example_configs/hashicorp-vault.md)
|
||||||
|
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||||
|
- [Home Assistant](example_configs/home-assistant.md)
|
||||||
|
- [Jellyfin](example_configs/jellyfin.md)
|
||||||
|
- [Jenkins](example_configs/jenkins.md)
|
||||||
|
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||||
|
- [Kasm](example_configs/kasm.md)
|
||||||
|
- [KeyCloak](example_configs/keycloak.md)
|
||||||
|
- [Kimai](example_configs/kimai.yaml)
|
||||||
|
- [LibreNMS](example_configs/librenms.md)
|
||||||
|
- [Maddy](example_configs/maddy.md)
|
||||||
|
- [Mastodon](example_configs/mastodon.env.example)
|
||||||
|
- [Matrix](example_configs/matrix_synapse.yml)
|
||||||
|
- [Mealie](example_configs/mealie.md)
|
||||||
|
- [Metabase](example_configs/metabase.md)
|
||||||
|
- [MegaRAC-BMC](example_configs/MegaRAC-SP-X-BMC.md)
|
||||||
|
- [MinIO](example_configs/minio.md)
|
||||||
|
- [Netbox](example_configs/netbox.md)
|
||||||
|
- [Nextcloud](example_configs/nextcloud.md)
|
||||||
|
- [Nexus](example_configs/nexus.md)
|
||||||
|
- [OCIS (OwnCloud Infinite Scale)](example_configs/ocis.md)
|
||||||
|
- [OneDev](example_configs/onedev.md)
|
||||||
|
- [Organizr](example_configs/Organizr.md)
|
||||||
|
- [Portainer](example_configs/portainer.md)
|
||||||
|
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
||||||
|
- [Prosody](example_configs/prosody.md)
|
||||||
|
- [Proxmox VE](example_configs/proxmox.md)
|
||||||
|
- [Radicale](example_configs/radicale.md)
|
||||||
|
- [Rancher](example_configs/rancher.md)
|
||||||
|
- [Seafile](example_configs/seafile.md)
|
||||||
|
- [Shaarli](example_configs/shaarli.md)
|
||||||
|
- [SonarQube](example_configs/sonarqube.md)
|
||||||
|
- [Squid](example_configs/squid.md)
|
||||||
|
- [Syncthing](example_configs/syncthing.md)
|
||||||
|
- [TheLounge](example_configs/thelounge.md)
|
||||||
|
- [Traccar](example_configs/traccar.xml)
|
||||||
|
- [Vaultwarden](example_configs/vaultwarden.md)
|
||||||
|
- [WeKan](example_configs/wekan.md)
|
||||||
|
- [WG Portal](example_configs/wg_portal.env.example)
|
||||||
|
- [WikiJS](example_configs/wikijs.md)
|
||||||
|
- [XBackBone](example_configs/xbackbone_config.php)
|
||||||
|
- [Zendto](example_configs/zendto.md)
|
||||||
|
- [Zitadel](example_configs/zitadel.md)
|
||||||
|
- [Zulip](example_configs/zulip.md)
|
||||||
|
|
||||||
|
### Incompatible services
|
||||||
|
|
||||||
|
Though we try to be maximally compatible, not every feature is supported; LLDAP
|
||||||
|
is not a fully-featured LDAP server, intentionally so.
|
||||||
|
|
||||||
|
LDAP browsing tools are generally not supported, though they could be. If you
|
||||||
|
need to use one but it behaves weirdly, please file a bug.
|
||||||
|
|
||||||
|
Some services use features that are not implemented, or require specific
|
||||||
|
attributes. You can try to create those attributes (see custom attributes in
|
||||||
|
the [Usage](#usage) section).
|
||||||
|
|
||||||
|
Finally, some services require password hashes so they can validate themselves
|
||||||
|
the user's password without contacting LLDAP. This is not and will not be
|
||||||
|
supported, it's incompatible with our password hashing scheme (a zero-knowledge
|
||||||
|
proof). Furthermore, it's generally not recommended in terms of security, since
|
||||||
|
it duplicates the places from which a password hash could leak.
|
||||||
|
|
||||||
|
In that category, the most prominent is Synology. It is, to date, the only
|
||||||
|
service that seems definitely incompatible with LLDAP.
|
||||||
|
|
||||||
|
## Migrating from SQLite
|
||||||
|
|
||||||
|
If you started with an SQLite database and would like to migrate to
|
||||||
|
MySQL/MariaDB or PostgreSQL, check out the [DB
|
||||||
|
migration docs](/docs/database_migration.md).
|
||||||
|
|
||||||
## Comparisons with other services
|
## Comparisons with other services
|
||||||
|
|
||||||
### vs OpenLDAP
|
### vs OpenLDAP
|
||||||
|
|
||||||
OpenLDAP is a monster of a service that implements all of LDAP and all of its
|
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
|
||||||
extensions, plus some of its own. That said, if you need all that flexibility,
|
all of LDAP and all of its extensions, plus some of its own. That said, if you
|
||||||
it might be what you need! Note that installation can be a bit painful
|
need all that flexibility, it might be what you need! Note that installation
|
||||||
(figuring out how to use `slapd`) and people have mixed experiences following
|
can be a bit painful (figuring out how to use `slapd`) and people have mixed
|
||||||
tutorials online. If you don't configure it properly, you might end up storing
|
experiences following tutorials online. If you don't configure it properly, you
|
||||||
passwords in clear, so a breach of your server would reveal all the stored
|
might end up storing passwords in clear, so a breach of your server would
|
||||||
passwords!
|
reveal all the stored passwords!
|
||||||
|
|
||||||
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
||||||
install one (not that many that look nice) and configure it.
|
install one (not that many look nice) and configure it.
|
||||||
|
|
||||||
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
||||||
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
|
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
|
||||||
|
However, it's not as flexible as OpenLDAP.
|
||||||
|
|
||||||
### vs FreeIPA
|
### vs FreeIPA
|
||||||
|
|
||||||
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
|
[FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
|
||||||
Samba, you name it, it has it. In addition to user management, it also does
|
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||||
security policies, single sign-on, certificate management, linux account
|
management, it also does security policies, single sign-on, certificate
|
||||||
management and so on.
|
management, linux account management and so on.
|
||||||
|
|
||||||
If you need all of that, go for it! Keep in mind that a more complex system is
|
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||||
more complex to maintain, though.
|
more complex to maintain, though.
|
||||||
@@ -306,25 +697,40 @@ LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
|
|||||||
configure (no messing around with DNS or security policies) and simpler to
|
configure (no messing around with DNS or security policies) and simpler to
|
||||||
use. It also comes conveniently packed in a docker container.
|
use. It also comes conveniently packed in a docker container.
|
||||||
|
|
||||||
|
### vs Kanidm
|
||||||
|
|
||||||
|
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
|
||||||
|
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
|
||||||
|
WebAuthn. It comes with a (read-only) LDAPS server.
|
||||||
|
|
||||||
|
It's fairly easy to install and does much more; but their LDAP server is
|
||||||
|
read-only, and by having more moving parts it is inherently more complex. If
|
||||||
|
you don't need to modify the users through LDAP and you're planning on
|
||||||
|
installing something like [KeyCloak](https://www.keycloak.org) to provide
|
||||||
|
modern identity protocols, check out Kanidm.
|
||||||
|
|
||||||
## I can't log in!
|
## I can't log in!
|
||||||
|
|
||||||
If you just set up the server, can get to the login page but the password you
|
If you just set up the server, can get to the login page but the password you
|
||||||
set isn't working, try the following:
|
set isn't working, try the following:
|
||||||
|
|
||||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
- If you have changed the admin password in the config after the first run, it
|
||||||
docker volume or mounted from the host filesystem.
|
won't be used (unless you force its use with `force_ldap_user_pass_reset`).
|
||||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
The config password is only for the initial admin creation.
|
||||||
or in the current directory). If there isn't, copy
|
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
docker volume or mounted from the host filesystem.
|
||||||
(passwords, secrets, ...).
|
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
or in the current directory). If there isn't, copy
|
||||||
you specified the DB URL, which defaults to the current directory). If
|
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||||
there isn't, check that the user running the command (user with ID 10001
|
(passwords, secrets, ...).
|
||||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
you specified the DB URL, which defaults to the current directory). If
|
||||||
- Make sure you restart the server.
|
there isn't, check that the user running the command (user with ID 10001
|
||||||
- If it's still not working, join the
|
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||||
|
- Make sure you restart the server.
|
||||||
|
- If it's still not working, join the
|
||||||
|
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lldap_app"
|
|
||||||
version = "0.4.1"
|
|
||||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||||
|
description = "Frontend for LLDAP"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
homepage = "https://github.com/lldap/lldap"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
name = "lldap_app"
|
||||||
|
repository = "https://github.com/lldap/lldap"
|
||||||
|
version = "0.6.2-alpha"
|
||||||
|
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
base64 = "0.13"
|
base64 = "0.13"
|
||||||
|
gloo-console = "0.2.3"
|
||||||
|
gloo-file = "0.2.3"
|
||||||
|
gloo-net = "*"
|
||||||
graphql_client = "0.10"
|
graphql_client = "0.10"
|
||||||
http = "0.2"
|
http = "0.2"
|
||||||
jwt = "0.13"
|
jwt = "0.13"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
validator = "=0.14"
|
url-escape = "0.1.1"
|
||||||
validator_derive = "*"
|
validator = "0.14"
|
||||||
|
validator_derive = "0.14"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
yew = "0.18"
|
wasm-bindgen-futures = "*"
|
||||||
yewtil = "*"
|
yew = "0.19.3"
|
||||||
yew-router = "0.15"
|
yew-router = "0.16"
|
||||||
|
|
||||||
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
||||||
indexmap = "=1.6.2"
|
indexmap = "=1.6.2"
|
||||||
@@ -28,12 +37,16 @@ version = "0.3"
|
|||||||
features = [
|
features = [
|
||||||
"Document",
|
"Document",
|
||||||
"Element",
|
"Element",
|
||||||
|
"Event",
|
||||||
"FileReader",
|
"FileReader",
|
||||||
|
"FormData",
|
||||||
"HtmlDocument",
|
"HtmlDocument",
|
||||||
|
"HtmlFormElement",
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
"HtmlOptionElement",
|
"HtmlOptionElement",
|
||||||
"HtmlOptionsCollection",
|
"HtmlOptionsCollection",
|
||||||
"HtmlSelectElement",
|
"HtmlSelectElement",
|
||||||
|
"SubmitEvent",
|
||||||
"console",
|
"console",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -54,11 +67,18 @@ version = "0.24"
|
|||||||
|
|
||||||
[dependencies.yew_form]
|
[dependencies.yew_form]
|
||||||
git = "https://github.com/jfbilodeau/yew_form"
|
git = "https://github.com/jfbilodeau/yew_form"
|
||||||
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
|
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||||
|
|
||||||
[dependencies.yew_form_derive]
|
[dependencies.yew_form_derive]
|
||||||
git = "https://github.com/jfbilodeau/yew_form"
|
git = "https://github.com/jfbilodeau/yew_form"
|
||||||
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
|
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[package.metadata.wasm-pack.profile.dev]
|
||||||
|
wasm-opt = ['--enable-bulk-memory']
|
||||||
|
[package.metadata.wasm-pack.profile.profiling]
|
||||||
|
wasm-opt = ['--enable-bulk-memory']
|
||||||
|
[package.metadata.wasm-pack.profile.release]
|
||||||
|
wasm-opt = ['--enable-bulk-memory']
|
||||||
|
|||||||
20
app/build.sh
20
app/build.sh
@@ -6,22 +6,12 @@ then
|
|||||||
>&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`'
|
>&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! which gzip > /dev/null 2>&1
|
||||||
wasm-pack build --target web
|
|
||||||
|
|
||||||
ROLLUP_BIN=$(which rollup 2>/dev/null)
|
|
||||||
if [ -f ../node_modules/rollup/dist/bin/rollup ]
|
|
||||||
then
|
then
|
||||||
ROLLUP_BIN=../node_modules/rollup/dist/bin/rollup
|
>&2 echo '`gzip` not found.'
|
||||||
elif [ -f node_modules/rollup/dist/bin/rollup ]
|
|
||||||
then
|
|
||||||
ROLLUP_BIN=node_modules/rollup/dist/bin/rollup
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$ROLLUP_BIN" ]
|
|
||||||
then
|
|
||||||
>&2 echo '`rollup` not found. Try running `npm install rollup`'
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap
|
wasm-pack build --target web --release
|
||||||
|
|
||||||
|
gzip -9 -k -f pkg/lldap_app_bg.wasm
|
||||||
|
|||||||
@@ -4,17 +4,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>LLDAP Administration</title>
|
<title>LLDAP Administration</title>
|
||||||
<script src="/pkg/bundle.js" defer></script>
|
<base href="/">
|
||||||
|
<script src="static/main.js" type="module" defer></script>
|
||||||
<link
|
<link
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
||||||
rel="preload stylesheet"
|
rel="preload stylesheet"
|
||||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
as="style" />
|
as="style" />
|
||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
|
||||||
|
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
||||||
@@ -29,11 +34,33 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/static/style.css" />
|
href="static/style.css" />
|
||||||
|
<script>
|
||||||
|
function inDarkMode(){
|
||||||
|
return darkmode.inDarkMode;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
|
||||||
|
LLDAP requires JavaScript, please switch to a compatible browser or
|
||||||
|
enable it.
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Detect if the user has WASM support. */
|
||||||
|
if (typeof WebAssembly === 'undefined') {
|
||||||
|
const pWASMMsg = document.createElement("p")
|
||||||
|
pWASMMsg.innerHTML = `
|
||||||
|
LLDAP requires WASM and JIT for JavaScript, please switch to a
|
||||||
|
compatible browser or enable it.
|
||||||
|
`
|
||||||
|
document.body.appendChild(pWASMMsg)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,15 +4,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>LLDAP Administration</title>
|
<title>LLDAP Administration</title>
|
||||||
<script src="/pkg/bundle.js" defer></script>
|
<script src="/static/main.js" type="module" defer></script>
|
||||||
<link
|
<link
|
||||||
href="/static/bootstrap.min.css"
|
href="/static/bootstrap-nightshade.min.css"
|
||||||
rel="preload stylesheet"
|
rel="preload stylesheet"
|
||||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||||
as="style" />
|
as="style" />
|
||||||
<script
|
<script
|
||||||
src="/static/bootstrap.bundle.min.js"
|
src="/static/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||||
|
<script
|
||||||
|
src="/static/darkmode.min.js"
|
||||||
|
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/static/bootstrap-icons.css"
|
href="/static/bootstrap-icons.css"
|
||||||
@@ -28,10 +31,32 @@
|
|||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="/static/style.css" />
|
href="/static/style.css" />
|
||||||
|
<script>
|
||||||
|
function inDarkMode(){
|
||||||
|
return darkmode.inDarkMode;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
|
||||||
|
LLDAP requires JavaScript, please switch to a compatible browser or
|
||||||
|
enable it.
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Detect if the user has WASM support. */
|
||||||
|
if (typeof WebAssembly === 'undefined') {
|
||||||
|
const pWASMMsg = document.createElement("p")
|
||||||
|
pWASMMsg.innerHTML = `
|
||||||
|
LLDAP requires WASM and JIT for JavaScript, please switch to a
|
||||||
|
compatible browser or enable it.
|
||||||
|
`
|
||||||
|
document.body.appendChild(pWASMMsg)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import init, { run_app } from './pkg/lldap_app.js';
|
|
||||||
async function main() {
|
|
||||||
await init('/pkg/lldap_app_bg.wasm');
|
|
||||||
run_app();
|
|
||||||
}
|
|
||||||
main()
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
mutation CreateGroup($name: String!) {
|
mutation CreateGroup($group: CreateGroupInput!) {
|
||||||
createGroup(name: $name) {
|
createGroupWithDetails(request: $group) {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
|||||||
5
app/queries/create_group_attribute.graphql
Normal file
5
app/queries/create_group_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!) {
|
||||||
|
addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: false) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/queries/create_user_attribute.graphql
Normal file
5
app/queries/create_user_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
|
||||||
|
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/queries/delete_group_attribute.graphql
Normal file
5
app/queries/delete_group_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation DeleteGroupAttributeQuery($name: String!) {
|
||||||
|
deleteGroupAttribute(name: $name) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/queries/delete_user_attribute.graphql
Normal file
5
app/queries/delete_user_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation DeleteUserAttributeQuery($name: String!) {
|
||||||
|
deleteUserAttribute(name: $name) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/queries/get_group_attributes_schema.graphql
Normal file
14
app/queries/get_group_attributes_schema.graphql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
query GetGroupAttributesSchema {
|
||||||
|
schema {
|
||||||
|
groupSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,5 +8,22 @@ query GetGroupDetails($id: Int!) {
|
|||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schema {
|
||||||
|
groupSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/queries/get_user_attributes_schema.graphql
Normal file
15
app/queries/get_user_attributes_schema.graphql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
query GetUserAttributesSchema {
|
||||||
|
schema {
|
||||||
|
userSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,30 @@ query GetUserDetails($id: String!) {
|
|||||||
user(userId: $id) {
|
user(userId: $id) {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
displayName
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
avatar
|
avatar
|
||||||
|
displayName
|
||||||
creationDate
|
creationDate
|
||||||
uuid
|
uuid
|
||||||
groups {
|
groups {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schema {
|
||||||
|
userSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/queries/update_group.graphql
Normal file
6
app/queries/update_group.graphql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mutation UpdateGroup($group: UpdateGroupInput!) {
|
||||||
|
updateGroup(group: $group) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -52,23 +52,25 @@ pub struct Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserListResponse(response) => {
|
Msg::UserListResponse(response) => {
|
||||||
self.user_list = Some(response?.users);
|
self.user_list = Some(response?.users);
|
||||||
self.common.cancel_task();
|
|
||||||
}
|
}
|
||||||
Msg::SubmitAddMember => return self.submit_add_member(),
|
Msg::SubmitAddMember => return self.submit_add_member(ctx),
|
||||||
Msg::AddMemberResponse(response) => {
|
Msg::AddMemberResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.common.cancel_task();
|
|
||||||
let user = self
|
let user = self
|
||||||
.selected_user
|
.selected_user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("Could not get selected user")
|
.expect("Could not get selected user")
|
||||||
.clone();
|
.clone();
|
||||||
// Remove the user from the dropdown.
|
// Remove the user from the dropdown.
|
||||||
self.common.on_user_added_to_group.emit(user);
|
ctx.props().on_user_added_to_group.emit(user);
|
||||||
}
|
}
|
||||||
Msg::SelectionChanged(option_props) => {
|
Msg::SelectionChanged(option_props) => {
|
||||||
let was_some = self.selected_user.is_some();
|
let was_some = self.selected_user.is_some();
|
||||||
@@ -88,23 +90,25 @@ impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AddGroupMemberComponent {
|
impl AddGroupMemberComponent {
|
||||||
fn get_user_list(&mut self) {
|
fn get_user_list(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<ListUserNames, _>(
|
self.common.call_graphql::<ListUserNames, _>(
|
||||||
|
ctx,
|
||||||
list_user_names::Variables { filters: None },
|
list_user_names::Variables { filters: None },
|
||||||
Msg::UserListResponse,
|
Msg::UserListResponse,
|
||||||
"Error trying to fetch user list",
|
"Error trying to fetch user list",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit_add_member(&mut self) -> Result<bool> {
|
fn submit_add_member(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
let user_id = match self.selected_user.clone() {
|
let user_id = match self.selected_user.clone() {
|
||||||
None => return Ok(false),
|
None => return Ok(false),
|
||||||
Some(user) => user.id,
|
Some(user) => user.id,
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<AddUserToGroup, _>(
|
self.common.call_graphql::<AddUserToGroup, _>(
|
||||||
|
ctx,
|
||||||
add_user_to_group::Variables {
|
add_user_to_group::Variables {
|
||||||
user: user_id,
|
user: user_id,
|
||||||
group: self.common.group_id,
|
group: ctx.props().group_id,
|
||||||
},
|
},
|
||||||
Msg::AddMemberResponse,
|
Msg::AddMemberResponse,
|
||||||
"Error trying to initiate adding the user to a group",
|
"Error trying to initiate adding the user to a group",
|
||||||
@@ -112,8 +116,8 @@ impl AddGroupMemberComponent {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
|
fn get_selectable_user_list(&self, ctx: &Context<Self>, user_list: &[User]) -> Vec<User> {
|
||||||
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
|
let user_groups = ctx.props().users.iter().collect::<HashSet<_>>();
|
||||||
user_list
|
user_list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|u| !user_groups.contains(u))
|
.filter(|u| !user_groups.contains(u))
|
||||||
@@ -126,41 +130,44 @@ impl Component for AddGroupMemberComponent {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut res = Self {
|
let mut res = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
user_list: None,
|
user_list: None,
|
||||||
selected_user: None,
|
selected_user: None,
|
||||||
};
|
};
|
||||||
res.get_user_list();
|
res.get_user_list(ctx);
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
if let Some(user_list) = &self.user_list {
|
if let Some(user_list) = &self.user_list {
|
||||||
let to_add_user_list = self.get_selectable_user_list(user_list);
|
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||||
#[allow(unused_braces)]
|
#[allow(unused_braces)]
|
||||||
let make_select_option = |user: User| {
|
let make_select_option = |user: User| {
|
||||||
|
let name = if user.display_name.is_empty() {
|
||||||
|
user.id.clone()
|
||||||
|
} else {
|
||||||
|
user.display_name.clone()
|
||||||
|
};
|
||||||
html_nested! {
|
html_nested! {
|
||||||
<SelectOption value=user.id.clone() text=user.display_name.clone() key=user.id />
|
<SelectOption value={user.id.clone()} text={name} key={user.id} />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-3">
|
||||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||||
{
|
{
|
||||||
to_add_user_list
|
to_add_user_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -169,12 +176,13 @@ impl Component for AddGroupMemberComponent {
|
|||||||
}
|
}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-1">
|
<div class="col-3">
|
||||||
<button
|
<button
|
||||||
class="btn btn-success"
|
class="btn btn-secondary"
|
||||||
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
disabled={self.selected_user.is_none() || self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
onclick={link.callback(|_| Msg::SubmitAddMember)}>
|
||||||
{"Add"}
|
<i class="bi-person-plus me-2"></i>
|
||||||
|
{"Add to group"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,16 +64,18 @@ pub struct Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::GroupListResponse(response) => {
|
Msg::GroupListResponse(response) => {
|
||||||
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
||||||
self.common.cancel_task();
|
|
||||||
}
|
}
|
||||||
Msg::SubmitAddGroup => return self.submit_add_group(),
|
Msg::SubmitAddGroup => return self.submit_add_group(ctx),
|
||||||
Msg::AddGroupResponse(response) => {
|
Msg::AddGroupResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.common.cancel_task();
|
|
||||||
// Adding the user to the group succeeded, we're not in the process of adding a
|
// Adding the user to the group succeeded, we're not in the process of adding a
|
||||||
// group anymore.
|
// group anymore.
|
||||||
let group = self
|
let group = self
|
||||||
@@ -82,7 +84,7 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
|||||||
.expect("Could not get selected group")
|
.expect("Could not get selected group")
|
||||||
.clone();
|
.clone();
|
||||||
// Remove the group from the dropdown.
|
// Remove the group from the dropdown.
|
||||||
self.common.on_user_added_to_group.emit(group);
|
ctx.props().on_user_added_to_group.emit(group);
|
||||||
}
|
}
|
||||||
Msg::SelectionChanged(option_props) => {
|
Msg::SelectionChanged(option_props) => {
|
||||||
let was_some = self.selected_group.is_some();
|
let was_some = self.selected_group.is_some();
|
||||||
@@ -102,22 +104,24 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AddUserToGroupComponent {
|
impl AddUserToGroupComponent {
|
||||||
fn get_group_list(&mut self) {
|
fn get_group_list(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<GetGroupList, _>(
|
self.common.call_graphql::<GetGroupList, _>(
|
||||||
|
ctx,
|
||||||
get_group_list::Variables,
|
get_group_list::Variables,
|
||||||
Msg::GroupListResponse,
|
Msg::GroupListResponse,
|
||||||
"Error trying to fetch group list",
|
"Error trying to fetch group list",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit_add_group(&mut self) -> Result<bool> {
|
fn submit_add_group(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
let group_id = match &self.selected_group {
|
let group_id = match &self.selected_group {
|
||||||
None => return Ok(false),
|
None => return Ok(false),
|
||||||
Some(group) => group.id,
|
Some(group) => group.id,
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<AddUserToGroup, _>(
|
self.common.call_graphql::<AddUserToGroup, _>(
|
||||||
|
ctx,
|
||||||
add_user_to_group::Variables {
|
add_user_to_group::Variables {
|
||||||
user: self.common.username.clone(),
|
user: ctx.props().username.clone(),
|
||||||
group: group_id,
|
group: group_id,
|
||||||
},
|
},
|
||||||
Msg::AddGroupResponse,
|
Msg::AddGroupResponse,
|
||||||
@@ -126,8 +130,8 @@ impl AddUserToGroupComponent {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
|
fn get_selectable_group_list(&self, props: &Props, group_list: &[Group]) -> Vec<Group> {
|
||||||
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
|
let user_groups = props.groups.iter().collect::<HashSet<_>>();
|
||||||
group_list
|
group_list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|g| !user_groups.contains(g))
|
.filter(|g| !user_groups.contains(g))
|
||||||
@@ -139,41 +143,39 @@ impl AddUserToGroupComponent {
|
|||||||
impl Component for AddUserToGroupComponent {
|
impl Component for AddUserToGroupComponent {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut res = Self {
|
let mut res = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
group_list: None,
|
group_list: None,
|
||||||
selected_group: None,
|
selected_group: None,
|
||||||
};
|
};
|
||||||
res.get_group_list();
|
res.get_group_list(ctx);
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
if let Some(group_list) = &self.group_list {
|
if let Some(group_list) = &self.group_list {
|
||||||
let to_add_group_list = self.get_selectable_group_list(group_list);
|
let to_add_group_list = self.get_selectable_group_list(ctx.props(), group_list);
|
||||||
#[allow(unused_braces)]
|
#[allow(unused_braces)]
|
||||||
let make_select_option = |group: Group| {
|
let make_select_option = |group: Group| {
|
||||||
html_nested! {
|
html_nested! {
|
||||||
<SelectOption value=group.id.to_string() text=group.display_name key=group.id />
|
<SelectOption value={group.id.to_string()} text={group.display_name} key={group.id} />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-3">
|
||||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||||
{
|
{
|
||||||
to_add_group_list
|
to_add_group_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -182,12 +184,13 @@ impl Component for AddUserToGroupComponent {
|
|||||||
}
|
}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-1">
|
<div class="col-sm-3">
|
||||||
<button
|
<button
|
||||||
class="btn btn-success"
|
class="btn btn-secondary"
|
||||||
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
disabled={self.selected_group.is_none() || self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
onclick={link.callback(|_| Msg::SubmitAddGroup)}>
|
||||||
{"Add"}
|
<i class="bi-person-plus me-2"></i>
|
||||||
|
{"Add to group"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,169 +1,201 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
|
banner::Banner,
|
||||||
change_password::ChangePasswordForm,
|
change_password::ChangePasswordForm,
|
||||||
create_group::CreateGroupForm,
|
create_group::CreateGroupForm,
|
||||||
|
create_group_attribute::CreateGroupAttributeForm,
|
||||||
create_user::CreateUserForm,
|
create_user::CreateUserForm,
|
||||||
|
create_user_attribute::CreateUserAttributeForm,
|
||||||
group_details::GroupDetails,
|
group_details::GroupDetails,
|
||||||
|
group_schema_table::ListGroupSchema,
|
||||||
group_table::GroupTable,
|
group_table::GroupTable,
|
||||||
login::LoginForm,
|
login::LoginForm,
|
||||||
logout::LogoutButton,
|
|
||||||
reset_password_step1::ResetPasswordStep1Form,
|
reset_password_step1::ResetPasswordStep1Form,
|
||||||
reset_password_step2::ResetPasswordStep2Form,
|
reset_password_step2::ResetPasswordStep2Form,
|
||||||
router::{AppRoute, Link, NavButton},
|
router::{AppRoute, Link, Redirect},
|
||||||
user_details::UserDetails,
|
user_details::UserDetails,
|
||||||
|
user_schema_table::ListUserSchema,
|
||||||
user_table::UserTable,
|
user_table::UserTable,
|
||||||
},
|
},
|
||||||
infra::cookies::get_cookie,
|
infra::{api::HostService, cookies::get_cookie},
|
||||||
};
|
|
||||||
use yew::prelude::*;
|
|
||||||
use yew::services::ConsoleService;
|
|
||||||
use yew_router::{
|
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
router::Router,
|
|
||||||
service::RouteService,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use gloo_console::error;
|
||||||
|
use yew::{
|
||||||
|
function_component,
|
||||||
|
html::Scope,
|
||||||
|
prelude::{html, Component, Html},
|
||||||
|
Context,
|
||||||
|
};
|
||||||
|
use yew_router::{
|
||||||
|
prelude::{History, Location},
|
||||||
|
scope_ext::RouterScopeExt,
|
||||||
|
BrowserRouter, Switch,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[function_component(AppContainer)]
|
||||||
|
pub fn app_container() -> Html {
|
||||||
|
html! {
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
link: ComponentLink<Self>,
|
|
||||||
user_info: Option<(String, bool)>,
|
user_info: Option<(String, bool)>,
|
||||||
redirect_to: Option<AppRoute>,
|
redirect_to: Option<AppRoute>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
password_reset_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Login((String, bool)),
|
Login((String, bool)),
|
||||||
Logout,
|
Logout,
|
||||||
|
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for App {
|
impl Component for App {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut app = Self {
|
let app = Self {
|
||||||
link,
|
|
||||||
user_info: get_cookie("user_id")
|
user_info: get_cookie("user_id")
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
ConsoleService::error(&e.to_string());
|
error!(&e.to_string());
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
.and_then(|u| {
|
.and_then(|u| {
|
||||||
get_cookie("is_admin")
|
get_cookie("is_admin")
|
||||||
.map(|so| so.map(|s| (u, s == "true")))
|
.map(|so| so.map(|s| (u, s == "true")))
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
ConsoleService::error(&e.to_string());
|
error!(&e.to_string());
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
redirect_to: Self::get_redirect_route(),
|
redirect_to: Self::get_redirect_route(ctx),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
password_reset_enabled: None,
|
||||||
};
|
};
|
||||||
app.apply_initial_redirections();
|
ctx.link().send_future(async move {
|
||||||
|
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
|
||||||
|
});
|
||||||
|
app.apply_initial_redirections(ctx);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
let history = ctx.link().history().unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Login((user_name, is_admin)) => {
|
Msg::Login((user_name, is_admin)) => {
|
||||||
self.user_info = Some((user_name.clone(), is_admin));
|
self.user_info = Some((user_name.clone(), is_admin));
|
||||||
self.route_dispatcher
|
history.push(self.redirect_to.take().unwrap_or_else(|| {
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(
|
if is_admin {
|
||||||
self.redirect_to.take().unwrap_or_else(|| {
|
AppRoute::ListUsers
|
||||||
if is_admin {
|
} else {
|
||||||
AppRoute::ListUsers
|
AppRoute::UserDetails {
|
||||||
} else {
|
user_id: user_name.clone(),
|
||||||
AppRoute::UserDetails(user_name.clone())
|
}
|
||||||
}
|
}
|
||||||
}),
|
}));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
Msg::Logout => {
|
Msg::Logout => {
|
||||||
self.user_info = None;
|
self.user_info = None;
|
||||||
self.redirect_to = None;
|
self.redirect_to = None;
|
||||||
|
history.push(AppRoute::Login);
|
||||||
|
}
|
||||||
|
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
|
||||||
|
self.password_reset_enabled = Some(enabled);
|
||||||
|
}
|
||||||
|
Msg::PasswordResetProbeFinished(Err(err)) => {
|
||||||
|
self.password_reset_enabled = Some(false);
|
||||||
|
error!(&format!(
|
||||||
|
"Could not probe for password reset support: {err:#}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if self.user_info.is_none() {
|
|
||||||
self.route_dispatcher
|
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let link = ctx.link().clone();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
let link = self.link.clone();
|
|
||||||
let is_admin = self.is_admin();
|
let is_admin = self.is_admin();
|
||||||
|
let username = self.user_info.clone().map(|(username, _)| username);
|
||||||
|
let password_reset_enabled = self.password_reset_enabled;
|
||||||
html! {
|
html! {
|
||||||
<div class="container shadow-sm py-3">
|
<div>
|
||||||
{self.view_banner()}
|
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||||
|
<div class="container py-3 bg-kug">
|
||||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||||
<div class="shadow-sm py-3" style="max-width: 1000px">
|
<main class="py-3" style="max-width: 1000px">
|
||||||
<Router<AppRoute>
|
<Switch<AppRoute>
|
||||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{self.view_footer()}
|
{self.view_footer()}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn get_redirect_route() -> Option<AppRoute> {
|
// Get the page to land on after logging in, defaulting to the index.
|
||||||
let route_service = RouteService::<()>::new();
|
fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
|
||||||
let current_route = route_service.get_path();
|
let route = ctx.link().history().unwrap().location().route::<AppRoute>();
|
||||||
if current_route.is_empty()
|
route.filter(|route| {
|
||||||
|| current_route == "/"
|
!matches!(
|
||||||
|| current_route.contains("login")
|
route,
|
||||||
|| current_route.contains("reset-password")
|
AppRoute::Index
|
||||||
{
|
| AppRoute::Login
|
||||||
None
|
| AppRoute::StartResetPassword
|
||||||
} else {
|
| AppRoute::FinishResetPassword { token: _ }
|
||||||
use yew_router::Switch;
|
)
|
||||||
AppRoute::from_route_part::<()>(current_route, None).0
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_initial_redirections(&mut self) {
|
fn apply_initial_redirections(&self, ctx: &Context<Self>) {
|
||||||
let route_service = RouteService::<()>::new();
|
let history = ctx.link().history().unwrap();
|
||||||
let current_route = route_service.get_path();
|
let route = history.location().route::<AppRoute>();
|
||||||
if current_route.contains("reset-password") {
|
let redirection = match (route, &self.user_info, &self.redirect_to) {
|
||||||
return;
|
(
|
||||||
}
|
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
|
||||||
match &self.user_info {
|
_,
|
||||||
None => {
|
_,
|
||||||
self.route_dispatcher
|
) => {
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
if self.password_reset_enabled == Some(false) {
|
||||||
|
Some(AppRoute::Login)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some((user_name, is_admin)) => match &self.redirect_to {
|
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
|
||||||
Some(url) => {
|
// User is logged in, a URL was given, don't redirect.
|
||||||
self.route_dispatcher
|
(_, Some(_), Some(_)) => None,
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
|
(_, Some((user_name, is_admin)), None) => {
|
||||||
|
if *is_admin {
|
||||||
|
Some(AppRoute::ListUsers)
|
||||||
|
} else {
|
||||||
|
Some(AppRoute::UserDetails {
|
||||||
|
user_id: user_name.clone(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
None => {
|
}
|
||||||
if *is_admin {
|
};
|
||||||
self.route_dispatcher
|
if let Some(redirect_to) = redirection {
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
|
history.push(redirect_to);
|
||||||
} else {
|
|
||||||
self.route_dispatcher
|
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(
|
|
||||||
AppRoute::UserDetails(user_name.clone()),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
|
fn dispatch_route(
|
||||||
|
switch: &AppRoute,
|
||||||
|
link: &Scope<Self>,
|
||||||
|
is_admin: bool,
|
||||||
|
password_reset_enabled: Option<bool>,
|
||||||
|
) -> Html {
|
||||||
match switch {
|
match switch {
|
||||||
AppRoute::Login => html! {
|
AppRoute::Login => html! {
|
||||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
<LoginForm on_logged_in={link.callback(Msg::Login)} password_reset_enabled={password_reset_enabled.unwrap_or(false)}/>
|
||||||
},
|
},
|
||||||
AppRoute::CreateUser => html! {
|
AppRoute::CreateUser => html! {
|
||||||
<CreateUserForm/>
|
<CreateUserForm/>
|
||||||
@@ -171,115 +203,71 @@ impl App {
|
|||||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||||
<div>
|
<div>
|
||||||
<UserTable />
|
<UserTable />
|
||||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
|
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
||||||
|
<i class="bi-person-plus me-2"></i>
|
||||||
|
{"Create a user"}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
},
|
},
|
||||||
AppRoute::CreateGroup => html! {
|
AppRoute::CreateGroup => html! {
|
||||||
<CreateGroupForm/>
|
<CreateGroupForm/>
|
||||||
},
|
},
|
||||||
|
AppRoute::CreateUserAttribute => html! {
|
||||||
|
<CreateUserAttributeForm/>
|
||||||
|
},
|
||||||
|
AppRoute::CreateGroupAttribute => html! {
|
||||||
|
<CreateGroupAttributeForm/>
|
||||||
|
},
|
||||||
AppRoute::ListGroups => html! {
|
AppRoute::ListGroups => html! {
|
||||||
<div>
|
<div>
|
||||||
<GroupTable />
|
<GroupTable />
|
||||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
|
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Create a group"}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
},
|
},
|
||||||
AppRoute::GroupDetails(group_id) => html! {
|
AppRoute::ListUserSchema => html! {
|
||||||
<GroupDetails group_id=group_id />
|
<ListUserSchema />
|
||||||
},
|
},
|
||||||
AppRoute::UserDetails(username) => html! {
|
AppRoute::ListGroupSchema => html! {
|
||||||
<UserDetails username=username is_admin=is_admin />
|
<ListGroupSchema />
|
||||||
},
|
},
|
||||||
AppRoute::ChangePassword(username) => html! {
|
AppRoute::GroupDetails { group_id } => html! {
|
||||||
<ChangePasswordForm username=username is_admin=is_admin />
|
<GroupDetails group_id={*group_id} is_admin={is_admin} />
|
||||||
},
|
},
|
||||||
AppRoute::StartResetPassword => html! {
|
AppRoute::UserDetails { user_id } => html! {
|
||||||
<ResetPasswordStep1Form />
|
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||||
},
|
},
|
||||||
AppRoute::FinishResetPassword(token) => html! {
|
AppRoute::ChangePassword { user_id } => html! {
|
||||||
<ResetPasswordStep2Form token=token />
|
<ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
|
||||||
},
|
},
|
||||||
}
|
AppRoute::StartResetPassword => match password_reset_enabled {
|
||||||
}
|
Some(true) => html! { <ResetPasswordStep1Form /> },
|
||||||
|
Some(false) => {
|
||||||
|
html! { <Redirect to={AppRoute::Login}/> }
|
||||||
|
}
|
||||||
|
|
||||||
fn view_banner(&self) -> Html {
|
None => html! {},
|
||||||
html! {
|
},
|
||||||
<header class="p-3 mb-4 border-bottom shadow-sm">
|
AppRoute::FinishResetPassword { token } => match password_reset_enabled {
|
||||||
<div class="container">
|
Some(true) => html! { <ResetPasswordStep2Form token={token.clone()} /> },
|
||||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
Some(false) => {
|
||||||
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 me-md-5 text-dark text-decoration-none">
|
html! { <Redirect to={AppRoute::Login}/> }
|
||||||
<h1>{"LLDAP"}</h1>
|
}
|
||||||
</a>
|
None => html! {},
|
||||||
|
},
|
||||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
|
||||||
{if self.is_admin() { html! {
|
|
||||||
<>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
classes="nav-link px-2 link-dark h4"
|
|
||||||
route=AppRoute::ListUsers>
|
|
||||||
{"Users"}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
classes="nav-link px-2 link-dark h4"
|
|
||||||
route=AppRoute::ListGroups>
|
|
||||||
{"Groups"}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
} } else { html!{} } }
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="dropdown text-end">
|
|
||||||
<a href="#"
|
|
||||||
class="d-block link-dark text-decoration-none dropdown-toggle"
|
|
||||||
id="dropdownUser"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
aria-expanded="false">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
fill="currentColor"
|
|
||||||
class="bi bi-person-circle"
|
|
||||||
viewBox="0 0 16 16">
|
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
|
||||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{if let Some((user_id, _)) = &self.user_info { html! {
|
|
||||||
<ul
|
|
||||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
|
||||||
aria-labelledby="dropdownUser1"
|
|
||||||
style="">
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
classes="dropdown-item"
|
|
||||||
route=AppRoute::UserDetails(user_id.clone())>
|
|
||||||
{"Profile"}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li><hr class="dropdown-divider" /></li>
|
|
||||||
<li>
|
|
||||||
<LogoutButton on_logged_out=self.link.callback(|_| Msg::Logout) />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
} } else { html!{} } }
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_footer(&self) -> Html {
|
fn view_footer(&self) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<footer class="text-center text-muted fixed-bottom bg-light">
|
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||||
<div>
|
<div>
|
||||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="https://github.com/nitnelave/lldap" class="me-4 text-reset">
|
<a href="https://github.com/lldap/lldap" class="me-4 text-reset">
|
||||||
<i class="bi-github"></i>
|
<i class="bi-github"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
|
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
|
||||||
@@ -290,7 +278,7 @@ impl App {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{"License "}<a href="https://github.com/nitnelave/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
<span>{"License "}<a href="https://github.com/lldap/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
}
|
}
|
||||||
|
|||||||
88
app/src/components/avatar.rs
Normal file
88
app/src/components/avatar.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
use crate::infra::functional::{use_graphql_call, LoadableResult};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Properties};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_user_details.graphql",
|
||||||
|
variables_derives = "Clone,PartialEq,Eq",
|
||||||
|
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetUserDetails;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub user: AttrValue,
|
||||||
|
#[prop_or(32)]
|
||||||
|
pub width: i32,
|
||||||
|
#[prop_or(32)]
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Avatar)]
|
||||||
|
pub fn avatar(props: &Props) -> Html {
|
||||||
|
let user_details = use_graphql_call::<GetUserDetails>(get_user_details::Variables {
|
||||||
|
id: props.user.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
match &(*user_details) {
|
||||||
|
LoadableResult::Loaded(Ok(response)) => {
|
||||||
|
let avatar = response.user.avatar.clone();
|
||||||
|
match &avatar {
|
||||||
|
Some(data) => html! {
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", data)}
|
||||||
|
style={format!("max-height:{}px;max-width:{}px;height:auto;width:auto;", props.height, props.width)}
|
||||||
|
alt="Avatar" />
|
||||||
|
},
|
||||||
|
None => html! {
|
||||||
|
<BlankAvatarDisplay
|
||||||
|
width={props.width}
|
||||||
|
height={props.height} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoadableResult::Loaded(Err(error)) => html! {
|
||||||
|
<BlankAvatarDisplay
|
||||||
|
error={error.to_string()}
|
||||||
|
width={props.width}
|
||||||
|
height={props.height} />
|
||||||
|
},
|
||||||
|
LoadableResult::Loading => html! {
|
||||||
|
<BlankAvatarDisplay
|
||||||
|
width={props.width}
|
||||||
|
height={props.height} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct BlankAvatarDisplayProps {
|
||||||
|
#[prop_or(None)]
|
||||||
|
pub error: Option<AttrValue>,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(BlankAvatarDisplay)]
|
||||||
|
fn blank_avatar_display(props: &BlankAvatarDisplayProps) -> Html {
|
||||||
|
let fill = match &props.error {
|
||||||
|
Some(_) => "red",
|
||||||
|
None => "currentColor",
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={props.width.to_string()}
|
||||||
|
height={props.height.to_string()}
|
||||||
|
fill={fill}
|
||||||
|
class="bi bi-person-circle"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<title>{props.error.clone().unwrap_or(AttrValue::Static("Avatar"))}</title>
|
||||||
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
|
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/src/components/banner.rs
Normal file
132
app/src/components/banner.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
use crate::components::{
|
||||||
|
avatar::Avatar,
|
||||||
|
logout::LogoutButton,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
};
|
||||||
|
use wasm_bindgen::prelude::wasm_bindgen;
|
||||||
|
use yew::{function_component, html, Callback, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub on_logged_out: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Banner)]
|
||||||
|
pub fn banner(props: &Props) -> Html {
|
||||||
|
html! {
|
||||||
|
<header class="p-2 mb-3 border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||||
|
<a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
||||||
|
<h2>{"LLDAP"}</h2>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||||
|
{if props.is_admin { html! {
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListUsers}>
|
||||||
|
<i class="bi-people me-2"></i>
|
||||||
|
{"Users"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListGroups}>
|
||||||
|
<i class="bi-collection me-2"></i>
|
||||||
|
{"Groups"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListUserSchema}>
|
||||||
|
<i class="bi-list-ul me-2"></i>
|
||||||
|
{"User schema"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListGroupSchema}>
|
||||||
|
<i class="bi-list-ul me-2"></i>
|
||||||
|
{"Group schema"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
} } else { html!{} } }
|
||||||
|
</ul>
|
||||||
|
<UserMenu username={props.username.clone()} on_logged_out={props.on_logged_out.clone()}/>
|
||||||
|
<DarkModeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct UserMenuProps {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub on_logged_out: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(UserMenu)]
|
||||||
|
fn user_menu(props: &UserMenuProps) -> Html {
|
||||||
|
match &props.username {
|
||||||
|
Some(username) => html! {
|
||||||
|
<div class="dropdown text-end">
|
||||||
|
<a href="#"
|
||||||
|
class="d-block nav-link text-decoration-none dropdown-toggle"
|
||||||
|
id="dropdownUser"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
<Avatar user={username.clone()} />
|
||||||
|
<span class="ms-2">
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||||
|
aria-labelledby="dropdownUser1"
|
||||||
|
style="">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="dropdown-item"
|
||||||
|
to={AppRoute::UserDetails{ user_id: username.to_string() }}>
|
||||||
|
{"View details"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<LogoutButton on_logged_out={props.on_logged_out.clone()} />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
},
|
||||||
|
_ => html! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = darkmode)]
|
||||||
|
fn toggleDarkMode(doSave: bool);
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
fn inDarkMode() -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(DarkModeToggle)]
|
||||||
|
fn dark_mode_toggle() -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
|
||||||
|
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,30 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use gloo_console::error;
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::{prelude::*, services::ConsoleService};
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq)]
|
#[derive(PartialEq, Eq, Default)]
|
||||||
enum OpaqueData {
|
enum OpaqueData {
|
||||||
|
#[default]
|
||||||
None,
|
None,
|
||||||
Login(opaque::client::login::ClientLogin),
|
Login(opaque::client::login::ClientLogin),
|
||||||
Registration(opaque::client::registration::ClientRegistration),
|
Registration(opaque::client::registration::ClientRegistration),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OpaqueData {
|
|
||||||
fn default() -> Self {
|
|
||||||
OpaqueData::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpaqueData {
|
impl OpaqueData {
|
||||||
fn take(&mut self) -> Self {
|
fn take(&mut self) -> Self {
|
||||||
std::mem::take(self)
|
std::mem::take(self)
|
||||||
@@ -61,7 +57,6 @@ pub struct ChangePasswordForm {
|
|||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: Form<FormModel>,
|
form: Form<FormModel>,
|
||||||
opaque_data: OpaqueData,
|
opaque_data: OpaqueData,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||||
@@ -80,15 +75,20 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use anyhow::Context;
|
||||||
match msg {
|
match msg {
|
||||||
Msg::FormUpdate => Ok(true),
|
Msg::FormUpdate => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
bail!("Check the form for errors");
|
bail!("Check the form for errors");
|
||||||
}
|
}
|
||||||
if self.common.is_admin {
|
if ctx.props().is_admin {
|
||||||
self.handle_msg(Msg::SubmitNewPassword)
|
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||||
} else {
|
} else {
|
||||||
let old_password = self.form.model().old_password;
|
let old_password = self.form.model().old_password;
|
||||||
if old_password.is_empty() {
|
if old_password.is_empty() {
|
||||||
@@ -100,14 +100,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
|||||||
.context("Could not initialize login")?;
|
.context("Could not initialize login")?;
|
||||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||||
let req = login::ClientLoginStartRequest {
|
let req = login::ClientLoginStartRequest {
|
||||||
username: self.common.username.clone(),
|
username: ctx.props().username.clone().into(),
|
||||||
login_start_request: login_start_request.message,
|
login_start_request: login_start_request.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::login_start,
|
ctx,
|
||||||
req,
|
HostService::login_start(req),
|
||||||
Msg::AuthenticationStartResponse,
|
Msg::AuthenticationStartResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,34 +119,33 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
|||||||
|e| {
|
|e| {
|
||||||
// Common error, we want to print a full error to the console but only a
|
// Common error, we want to print a full error to the console but only a
|
||||||
// simple one to the user.
|
// simple one to the user.
|
||||||
ConsoleService::error(&format!(
|
error!(&format!("Invalid username or password: {}", e));
|
||||||
"Invalid username or password: {}",
|
|
||||||
e
|
|
||||||
));
|
|
||||||
anyhow!("Invalid username or password")
|
anyhow!("Invalid username or password")
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
_ => panic!("Unexpected data in opaque_data field"),
|
_ => panic!("Unexpected data in opaque_data field"),
|
||||||
};
|
};
|
||||||
self.handle_msg(Msg::SubmitNewPassword)
|
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||||
}
|
}
|
||||||
Msg::SubmitNewPassword => {
|
Msg::SubmitNewPassword => {
|
||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
let new_password = self.form.model().password;
|
let new_password = self.form.model().password;
|
||||||
let registration_start_request =
|
let registration_start_request = opaque::client::registration::start_registration(
|
||||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
new_password.as_bytes(),
|
||||||
.context("Could not initiate password change")?;
|
&mut rng,
|
||||||
|
)
|
||||||
|
.context("Could not initiate password change")?;
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: self.common.username.clone(),
|
username: ctx.props().username.clone().into(),
|
||||||
registration_start_request: registration_start_request.message,
|
registration_start_request: registration_start_request.message,
|
||||||
};
|
};
|
||||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_start,
|
ctx,
|
||||||
req,
|
HostService::register_start(req),
|
||||||
Msg::RegistrationStartResponse,
|
Msg::RegistrationStartResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::RegistrationStartResponse(res) => {
|
Msg::RegistrationStartResponse(res) => {
|
||||||
@@ -166,22 +165,20 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
|||||||
registration_upload: registration_finish.message,
|
registration_upload: registration_finish.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_finish,
|
ctx,
|
||||||
req,
|
HostService::register_finish(req),
|
||||||
Msg::RegistrationFinishResponse,
|
Msg::RegistrationFinishResponse,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
_ => panic!("Unexpected data in opaque_data field"),
|
_ => panic!("Unexpected data in opaque_data field"),
|
||||||
}?;
|
};
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
if response.is_ok() {
|
if response.is_ok() {
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::UserDetails {
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(
|
user_id: ctx.props().username.clone(),
|
||||||
AppRoute::UserDetails(self.common.username.clone()),
|
});
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
response?;
|
response?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -198,116 +195,76 @@ impl Component for ChangePasswordForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
ChangePasswordForm {
|
ChangePasswordForm {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||||
opaque_data: OpaqueData::None,
|
opaque_data: OpaqueData::None,
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let is_admin = ctx.props().is_admin;
|
||||||
}
|
let link = ctx.link();
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
let is_admin = self.common.is_admin;
|
|
||||||
type Field = yew_form::Field<FormModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<form
|
<div class="mb-2 mt-2">
|
||||||
class="form">
|
<h5 class="fw-bold">
|
||||||
{if !is_admin { html! {
|
{"Change password"}
|
||||||
<div class="form-group row">
|
</h5>
|
||||||
<label for="old_password"
|
</div>
|
||||||
class="form-label col-sm-2 col-form-label">
|
{
|
||||||
{"Current password*:"}
|
if let Some(e) = &self.common.error {
|
||||||
</label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
field_name="old_password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="current-password"
|
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("old_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}} else { html! {} }}
|
|
||||||
<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"
|
|
||||||
input_type="password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
|
||||||
<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"
|
|
||||||
input_type="password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("confirm_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<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! {
|
html! {
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger mt-3 mb-3">
|
||||||
{e.to_string() }
|
{e.to_string() }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else { html! {} }
|
} else { html! {} }
|
||||||
}
|
}
|
||||||
<div>
|
<form class="form">
|
||||||
<NavButton
|
{if !is_admin { html! {
|
||||||
classes="btn btn-primary"
|
<Field<FormModel>
|
||||||
route=AppRoute::UserDetails(self.common.username.clone())>
|
form={&self.form}
|
||||||
{"Back"}
|
required=true
|
||||||
</NavButton>
|
label="Current password"
|
||||||
</div>
|
field_name="old_password"
|
||||||
|
input_type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
|
}} else { html! {} }}
|
||||||
|
<Field<FormModel>
|
||||||
|
form={&self.form}
|
||||||
|
required=true
|
||||||
|
label="New password"
|
||||||
|
field_name="password"
|
||||||
|
input_type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
|
<Field<FormModel>
|
||||||
|
form={&self.form}
|
||||||
|
required=true
|
||||||
|
label="Confirm password"
|
||||||
|
field_name="confirm_password"
|
||||||
|
input_type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
|
<Submit
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}
|
||||||
|
text="Save changes" >
|
||||||
|
<Link
|
||||||
|
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
||||||
|
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
||||||
|
<i class="bi-arrow-return-left me-2"></i>
|
||||||
|
{"Back"}
|
||||||
|
</Link>
|
||||||
|
</Submit>
|
||||||
|
</form>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,56 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::AppRoute,
|
components::{
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
form::{
|
||||||
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
|
field::Field,
|
||||||
|
submit::Submit,
|
||||||
|
},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::{
|
||||||
|
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
|
||||||
|
IsAdmin,
|
||||||
|
},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{ensure, Result};
|
||||||
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::ConsoleService;
|
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
#[derive(GraphQLQuery)]
|
||||||
};
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_group_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetGroupAttributesSchema;
|
||||||
|
|
||||||
|
use get_group_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute =
|
||||||
|
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||||
|
fn from(attr: &Attribute) -> Self {
|
||||||
|
Self {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
is_list: attr.is_list,
|
||||||
|
is_readonly: attr.is_readonly,
|
||||||
|
is_editable: false, // Need to be admin to edit it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -24,8 +63,9 @@ pub struct CreateGroup;
|
|||||||
|
|
||||||
pub struct CreateGroupForm {
|
pub struct CreateGroupForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
form: yew_form::Form<CreateGroupModel>,
|
form: yew_form::Form<CreateGroupModel>,
|
||||||
|
attributes_schema: Option<Vec<Attribute>>,
|
||||||
|
form_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
@@ -36,23 +76,50 @@ pub struct CreateGroupModel {
|
|||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Update,
|
Update,
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
SubmitForm,
|
SubmitForm,
|
||||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
if !self.form.validate() {
|
ensure!(self.form.validate(), "Check the form for errors");
|
||||||
bail!("Check the form for errors");
|
|
||||||
}
|
let all_values = read_all_form_attributes(
|
||||||
|
self.attributes_schema.iter().flatten(),
|
||||||
|
&self.form_ref,
|
||||||
|
IsAdmin(true),
|
||||||
|
EmailIsRequired(false),
|
||||||
|
)?;
|
||||||
|
let attributes = Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| !a.values.is_empty())
|
||||||
|
.map(
|
||||||
|
|AttributeValue { name, values }| create_group::AttributeValueInput {
|
||||||
|
name,
|
||||||
|
value: values,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
let req = create_group::Variables {
|
let req = create_group::Variables {
|
||||||
name: model.groupname,
|
group: create_group::CreateGroupInput {
|
||||||
|
displayName: model.groupname,
|
||||||
|
attributes,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateGroup, _>(
|
self.common.call_graphql::<CreateGroup, _>(
|
||||||
|
ctx,
|
||||||
req,
|
req,
|
||||||
Msg::CreateGroupResponse,
|
Msg::CreateGroupResponse,
|
||||||
"Error trying to create group",
|
"Error trying to create group",
|
||||||
@@ -60,12 +127,16 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::CreateGroupResponse(response) => {
|
Msg::CreateGroupResponse(response) => {
|
||||||
ConsoleService::log(&format!(
|
log!(&format!(
|
||||||
"Created group '{}'",
|
"Created group '{}'",
|
||||||
&response?.create_group.display_name
|
&response?.create_group_with_details.display_name
|
||||||
));
|
));
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes_schema =
|
||||||
|
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,58 +151,54 @@ impl Component for CreateGroupForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
Self {
|
let mut component = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||||
}
|
attributes_schema: None,
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
|
};
|
||||||
|
component
|
||||||
|
.common
|
||||||
|
.call_graphql::<GetGroupAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_group_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch group schema",
|
||||||
|
);
|
||||||
|
component
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<CreateGroupModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form shadow-sm py-3" style="max-width: 636px">
|
<form class="form py-3" style="max-width: 636px"
|
||||||
|
ref={self.form_ref.clone()}>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group row mb-3">
|
<Field<CreateGroupModel>
|
||||||
<label for="groupname"
|
form={&self.form}
|
||||||
class="form-label col-4 col-form-label">
|
required=true
|
||||||
{"Group name*:"}
|
label="Group name"
|
||||||
</label>
|
field_name="groupname"
|
||||||
<div class="col-8">
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<Field
|
{
|
||||||
form=&self.form
|
self.attributes_schema
|
||||||
field_name="groupname"
|
.iter()
|
||||||
class="form-control"
|
.flatten()
|
||||||
class_invalid="is-invalid has-error"
|
.filter(|a| !a.is_readonly && a.name != "display_name")
|
||||||
class_valid="has-success"
|
.map(get_custom_attribute_input)
|
||||||
autocomplete="groupname"
|
.collect::<Vec<_>>()
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
}
|
||||||
<div class="invalid-feedback">
|
<Submit
|
||||||
{&self.form.field_message("groupname")}
|
disabled={self.common.is_task_running()}
|
||||||
</div>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row justify-content-center">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary col-auto col-form-label"
|
|
||||||
type="submit"
|
|
||||||
disabled=self.common.is_task_running()
|
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
|
||||||
{"Submit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
@@ -145,3 +212,21 @@ impl Component for CreateGroupForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||||
|
if attribute_schema.is_list {
|
||||||
|
html! {
|
||||||
|
<ListAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<SingleAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
168
app/src/components/create_group_attribute.rs
Normal file
168
app/src/components/create_group_attribute.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::{validate_attribute_type, AttributeType},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator_derive::Validate;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_form_derive::Model;
|
||||||
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/create_group_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct CreateGroupAttribute;
|
||||||
|
|
||||||
|
convert_attribute_type!(create_group_attribute::AttributeType);
|
||||||
|
|
||||||
|
pub struct CreateGroupAttributeForm {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
form: yew_form::Form<CreateGroupAttributeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||||
|
pub struct CreateGroupAttributeModel {
|
||||||
|
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||||
|
attribute_name: String,
|
||||||
|
#[validate(custom = "validate_attribute_type")]
|
||||||
|
attribute_type: String,
|
||||||
|
is_list: bool,
|
||||||
|
is_visible: bool, // remove when backend doesn't return group attributes for normal users
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
Update,
|
||||||
|
SubmitForm,
|
||||||
|
CreateGroupAttributeResponse(Result<create_group_attribute::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => Ok(true),
|
||||||
|
Msg::SubmitForm => {
|
||||||
|
if !self.form.validate() {
|
||||||
|
bail!("Check the form for errors");
|
||||||
|
}
|
||||||
|
let model = self.form.model();
|
||||||
|
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||||
|
let req = create_group_attribute::Variables {
|
||||||
|
name: model.attribute_name,
|
||||||
|
attribute_type: create_group_attribute::AttributeType::from(attribute_type),
|
||||||
|
is_list: model.is_list,
|
||||||
|
is_visible: model.is_visible,
|
||||||
|
};
|
||||||
|
self.common.call_graphql::<CreateGroupAttribute, _>(
|
||||||
|
ctx,
|
||||||
|
req,
|
||||||
|
Msg::CreateGroupAttributeResponse,
|
||||||
|
"Error trying to create group attribute",
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::CreateGroupAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
let model = self.form.model();
|
||||||
|
log!(&format!(
|
||||||
|
"Created group attribute '{}'",
|
||||||
|
model.attribute_name
|
||||||
|
));
|
||||||
|
ctx.link()
|
||||||
|
.history()
|
||||||
|
.unwrap()
|
||||||
|
.push(AppRoute::ListGroupSchema);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for CreateGroupAttributeForm {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = ();
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
let model = CreateGroupAttributeModel {
|
||||||
|
attribute_type: AttributeType::String.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
form: yew_form::Form::<CreateGroupAttributeModel>::new(model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
html! {
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<form class="form py-3" style="max-width: 636px">
|
||||||
|
<h5 class="fw-bold">{"Create a group attribute"}</h5>
|
||||||
|
<Field<CreateGroupAttributeModel>
|
||||||
|
label="Name"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Select<CreateGroupAttributeModel>
|
||||||
|
label="Type"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_type"
|
||||||
|
oninput={link.callback(|_| Msg::Update)}>
|
||||||
|
<option selected=true value="String">{"String"}</option>
|
||||||
|
<option value="Integer">{"Integer"}</option>
|
||||||
|
<option value="Jpeg">{"Jpeg"}</option>
|
||||||
|
<option value="DateTime">{"DateTime"}</option>
|
||||||
|
</Select<CreateGroupAttributeModel>>
|
||||||
|
<CheckBox<CreateGroupAttributeModel>
|
||||||
|
label="Multiple values"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_list"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateGroupAttributeModel>
|
||||||
|
label="Visible to users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_visible"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<Submit
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||||
|
</form>
|
||||||
|
{ if let Some(e) = &self.common.error {
|
||||||
|
html! {
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,57 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::AppRoute,
|
components::{
|
||||||
|
form::{
|
||||||
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
|
field::Field,
|
||||||
|
submit::Submit,
|
||||||
|
},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::{
|
||||||
|
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
|
||||||
|
IsAdmin,
|
||||||
|
},
|
||||||
|
schema::AttributeType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{ensure, Result};
|
||||||
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{opaque, registration};
|
use lldap_auth::{opaque, registration};
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::ConsoleService;
|
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
#[derive(GraphQLQuery)]
|
||||||
};
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_user_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetUserAttributesSchema;
|
||||||
|
|
||||||
|
use get_user_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||||
|
fn from(attr: &Attribute) -> Self {
|
||||||
|
Self {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
is_list: attr.is_list,
|
||||||
|
is_readonly: attr.is_readonly,
|
||||||
|
is_editable: attr.is_editable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -28,20 +64,15 @@ pub struct CreateUser;
|
|||||||
|
|
||||||
pub struct CreateUserForm {
|
pub struct CreateUserForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
form: yew_form::Form<CreateUserModel>,
|
form: yew_form::Form<CreateUserModel>,
|
||||||
|
attributes_schema: Option<Vec<Attribute>>,
|
||||||
|
form_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct CreateUserModel {
|
pub struct CreateUserModel {
|
||||||
#[validate(length(min = 1, message = "Username is required"))]
|
#[validate(length(min = 1, message = "Username is required"))]
|
||||||
username: String,
|
username: String,
|
||||||
#[validate(email(message = "A valid email is required"))]
|
|
||||||
email: String,
|
|
||||||
#[validate(length(min = 1, message = "Display name is required"))]
|
|
||||||
display_name: String,
|
|
||||||
first_name: String,
|
|
||||||
last_name: String,
|
|
||||||
#[validate(custom(
|
#[validate(custom(
|
||||||
function = "empty_or_long",
|
function = "empty_or_long",
|
||||||
message = "Password should be longer than 8 characters (or left empty)"
|
message = "Password should be longer than 8 characters (or left empty)"
|
||||||
@@ -61,6 +92,7 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
|||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Update,
|
Update,
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
SubmitForm,
|
SubmitForm,
|
||||||
CreateUserResponse(Result<create_user::ResponseData>),
|
CreateUserResponse(Result<create_user::ResponseData>),
|
||||||
SuccessfulCreation,
|
SuccessfulCreation,
|
||||||
@@ -74,26 +106,54 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes_schema =
|
||||||
|
Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
if !self.form.validate() {
|
ensure!(self.form.validate(), "Check the form for errors");
|
||||||
bail!("Check the form for errors");
|
|
||||||
}
|
let all_values = read_all_form_attributes(
|
||||||
|
self.attributes_schema.iter().flatten(),
|
||||||
|
&self.form_ref,
|
||||||
|
IsAdmin(true),
|
||||||
|
EmailIsRequired(true),
|
||||||
|
)?;
|
||||||
|
let attributes = Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| !a.values.is_empty())
|
||||||
|
.map(
|
||||||
|
|AttributeValue { name, values }| create_user::AttributeValueInput {
|
||||||
|
name,
|
||||||
|
value: values,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
|
|
||||||
let req = create_user::Variables {
|
let req = create_user::Variables {
|
||||||
user: create_user::CreateUserInput {
|
user: create_user::CreateUserInput {
|
||||||
id: model.username,
|
id: model.username,
|
||||||
email: model.email,
|
email: None,
|
||||||
displayName: to_option(model.display_name),
|
displayName: None,
|
||||||
firstName: to_option(model.first_name),
|
firstName: None,
|
||||||
lastName: to_option(model.last_name),
|
lastName: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
|
attributes,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateUser, _>(
|
self.common.call_graphql::<CreateUser, _>(
|
||||||
|
ctx,
|
||||||
req,
|
req,
|
||||||
Msg::CreateUserResponse,
|
Msg::CreateUserResponse,
|
||||||
"Error trying to create user",
|
"Error trying to create user",
|
||||||
@@ -103,7 +163,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
Msg::CreateUserResponse(r) => {
|
Msg::CreateUserResponse(r) => {
|
||||||
match r {
|
match r {
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
Ok(r) => ConsoleService::log(&format!(
|
Ok(r) => log!(&format!(
|
||||||
"Created user '{}' at '{}'",
|
"Created user '{}' at '{}'",
|
||||||
&r.create_user.id, &r.create_user.creation_date
|
&r.create_user.id, &r.create_user.creation_date
|
||||||
)),
|
)),
|
||||||
@@ -117,18 +177,20 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
let opaque::client::registration::ClientRegistrationStartResult {
|
let opaque::client::registration::ClientRegistrationStartResult {
|
||||||
state,
|
state,
|
||||||
message,
|
message,
|
||||||
} = opaque::client::registration::start_registration(&password, &mut rng)?;
|
} = opaque::client::registration::start_registration(
|
||||||
|
password.as_bytes(),
|
||||||
|
&mut rng,
|
||||||
|
)?;
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: user_id,
|
username: user_id.into(),
|
||||||
registration_start_request: message,
|
registration_start_request: message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common
|
||||||
.call_backend(HostService::register_start, req, move |r| {
|
.call_backend(ctx, HostService::register_start(req), move |r| {
|
||||||
Msg::RegistrationStartResponse((state, r))
|
Msg::RegistrationStartResponse((state, r))
|
||||||
})
|
});
|
||||||
.context("Error trying to create user")?;
|
|
||||||
} else {
|
} else {
|
||||||
self.update(Msg::SuccessfulCreation);
|
self.update(ctx, Msg::SuccessfulCreation);
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
@@ -144,22 +206,19 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
server_data: response.server_data,
|
server_data: response.server_data,
|
||||||
registration_upload: registration_upload.message,
|
registration_upload: registration_upload.message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common.call_backend(
|
||||||
.call_backend(
|
ctx,
|
||||||
HostService::register_finish,
|
HostService::register_finish(req),
|
||||||
req,
|
Msg::RegistrationFinishResponse,
|
||||||
Msg::RegistrationFinishResponse,
|
);
|
||||||
)
|
|
||||||
.context("Error trying to register user")?;
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.handle_msg(Msg::SuccessfulCreation)
|
self.handle_msg(ctx, Msg::SuccessfulCreation)
|
||||||
}
|
}
|
||||||
Msg::SuccessfulCreation => {
|
Msg::SuccessfulCreation => {
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::ListUsers);
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListUsers)));
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,177 +233,66 @@ impl Component for CreateUserForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
Self {
|
let mut component = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||||
}
|
attributes_schema: None,
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
|
};
|
||||||
|
component.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_user_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch user schema",
|
||||||
|
);
|
||||||
|
component
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<CreateUserModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form shadow-sm py-3" style="max-width: 636px">
|
<form class="form py-3"
|
||||||
<div class="row mb-3">
|
ref={self.form_ref.clone()}>
|
||||||
<h5 class="fw-bold">{"Create a user"}</h5>
|
<Field<CreateUserModel>
|
||||||
</div>
|
form={&self.form}
|
||||||
<div class="form-group row mb-3">
|
required=true
|
||||||
<label for="username"
|
label="User name"
|
||||||
class="form-label col-4 col-form-label">
|
field_name="username"
|
||||||
{"User name*:"}
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</label>
|
{
|
||||||
<div class="col-8">
|
self.attributes_schema
|
||||||
<Field
|
.iter()
|
||||||
form=&self.form
|
.flatten()
|
||||||
field_name="username"
|
.filter(|a| !a.is_readonly)
|
||||||
class="form-control"
|
.map(get_custom_attribute_input)
|
||||||
class_invalid="is-invalid has-error"
|
.collect::<Vec<_>>()
|
||||||
class_valid="has-success"
|
}
|
||||||
autocomplete="username"
|
<Field<CreateUserModel>
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
form={&self.form}
|
||||||
<div class="invalid-feedback">
|
label="Password"
|
||||||
{&self.form.field_message("username")}
|
field_name="password"
|
||||||
</div>
|
input_type="password"
|
||||||
</div>
|
autocomplete="new-password"
|
||||||
</div>
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="form-group row mb-3">
|
<Field<CreateUserModel>
|
||||||
<label for="email"
|
form={&self.form}
|
||||||
class="form-label col-4 col-form-label">
|
label="Confirm password"
|
||||||
{"Email*:"}
|
field_name="confirm_password"
|
||||||
</label>
|
input_type="password"
|
||||||
<div class="col-8">
|
autocomplete="new-password"
|
||||||
<Field
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
form=&self.form
|
<Submit
|
||||||
input_type="email"
|
disabled={self.common.is_task_running()}
|
||||||
field_name="email"
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="email"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("email")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="display-name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Display name*:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
autocomplete="name"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
field_name="display_name"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("display_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="first-name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"First name:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
autocomplete="given-name"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
field_name="first_name"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("first_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="last-name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Last name:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
autocomplete="family-name"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
field_name="last_name"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("last_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="password"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Password:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
input_type="password"
|
|
||||||
field_name="password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="confirm_password"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Confirm password:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
input_type="password"
|
|
||||||
field_name="confirm_password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("confirm_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row justify-content-center">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary col-auto col-form-label mt-4"
|
|
||||||
disabled=self.common.is_task_running()
|
|
||||||
type="submit"
|
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
|
||||||
{"Submit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.common.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() }
|
||||||
@@ -356,3 +304,21 @@ impl Component for CreateUserForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||||
|
if attribute_schema.is_list {
|
||||||
|
html! {
|
||||||
|
<ListAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<SingleAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
175
app/src/components/create_user_attribute.rs
Normal file
175
app/src/components/create_user_attribute.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::{validate_attribute_type, AttributeType},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator_derive::Validate;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_form_derive::Model;
|
||||||
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/create_user_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct CreateUserAttribute;
|
||||||
|
|
||||||
|
convert_attribute_type!(create_user_attribute::AttributeType);
|
||||||
|
|
||||||
|
pub struct CreateUserAttributeForm {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
form: yew_form::Form<CreateUserAttributeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||||
|
pub struct CreateUserAttributeModel {
|
||||||
|
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||||
|
attribute_name: String,
|
||||||
|
#[validate(custom = "validate_attribute_type")]
|
||||||
|
attribute_type: String,
|
||||||
|
is_editable: bool,
|
||||||
|
is_list: bool,
|
||||||
|
is_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
Update,
|
||||||
|
SubmitForm,
|
||||||
|
CreateUserAttributeResponse(Result<create_user_attribute::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => Ok(true),
|
||||||
|
Msg::SubmitForm => {
|
||||||
|
if !self.form.validate() {
|
||||||
|
bail!("Check the form for errors");
|
||||||
|
}
|
||||||
|
let model = self.form.model();
|
||||||
|
if model.is_editable && !model.is_visible {
|
||||||
|
bail!("Editable attributes must also be visible");
|
||||||
|
}
|
||||||
|
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||||
|
let req = create_user_attribute::Variables {
|
||||||
|
name: model.attribute_name,
|
||||||
|
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
|
||||||
|
is_editable: model.is_editable,
|
||||||
|
is_list: model.is_list,
|
||||||
|
is_visible: model.is_visible,
|
||||||
|
};
|
||||||
|
self.common.call_graphql::<CreateUserAttribute, _>(
|
||||||
|
ctx,
|
||||||
|
req,
|
||||||
|
Msg::CreateUserAttributeResponse,
|
||||||
|
"Error trying to create user attribute",
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::CreateUserAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
let model = self.form.model();
|
||||||
|
log!(&format!(
|
||||||
|
"Created user attribute '{}'",
|
||||||
|
model.attribute_name
|
||||||
|
));
|
||||||
|
ctx.link().history().unwrap().push(AppRoute::ListUserSchema);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for CreateUserAttributeForm {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = ();
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
let model = CreateUserAttributeModel {
|
||||||
|
attribute_type: AttributeType::String.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
form: yew_form::Form::<CreateUserAttributeModel>::new(model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
html! {
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<form class="form py-3" style="max-width: 636px">
|
||||||
|
<h5 class="fw-bold">{"Create a user attribute"}</h5>
|
||||||
|
<Field<CreateUserAttributeModel>
|
||||||
|
label="Name"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Select<CreateUserAttributeModel>
|
||||||
|
label="Type"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_type"
|
||||||
|
oninput={link.callback(|_| Msg::Update)}>
|
||||||
|
<option selected=true value="String">{"String"}</option>
|
||||||
|
<option value="Integer">{"Integer"}</option>
|
||||||
|
<option value="Jpeg">{"Jpeg"}</option>
|
||||||
|
<option value="DateTime">{"DateTime"}</option>
|
||||||
|
</Select<CreateUserAttributeModel>>
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Multiple values"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_list"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Visible to users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_visible"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Editable by users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_editable"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<Submit
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||||
|
</form>
|
||||||
|
{ if let Some(e) = &self.common.error {
|
||||||
|
html! {
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,16 +39,21 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ClickedDeleteGroup => {
|
Msg::ClickedDeleteGroup => {
|
||||||
self.modal.as_ref().expect("modal not initialized").show();
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
}
|
}
|
||||||
Msg::ConfirmDeleteGroup => {
|
Msg::ConfirmDeleteGroup => {
|
||||||
self.update(Msg::DismissModal);
|
self.update(ctx, Msg::DismissModal);
|
||||||
self.common.call_graphql::<DeleteGroupQuery, _>(
|
self.common.call_graphql::<DeleteGroupQuery, _>(
|
||||||
|
ctx,
|
||||||
delete_group_query::Variables {
|
delete_group_query::Variables {
|
||||||
group_id: self.common.group.id,
|
group_id: ctx.props().group.id,
|
||||||
},
|
},
|
||||||
Msg::DeleteGroupResponse,
|
Msg::DeleteGroupResponse,
|
||||||
"Error trying to delete group",
|
"Error trying to delete group",
|
||||||
@@ -58,12 +63,8 @@ impl CommonComponent<DeleteGroup> for DeleteGroup {
|
|||||||
self.modal.as_ref().expect("modal not initialized").hide();
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
}
|
}
|
||||||
Msg::DeleteGroupResponse(response) => {
|
Msg::DeleteGroupResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
response?;
|
response?;
|
||||||
self.common
|
ctx.props().on_group_deleted.emit(ctx.props().group.id);
|
||||||
.props
|
|
||||||
.on_group_deleted
|
|
||||||
.emit(self.common.group.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -78,15 +79,15 @@ impl Component for DeleteGroup {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = DeleteGroupProps;
|
type Properties = DeleteGroupProps;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
modal: None,
|
modal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered(&mut self, first_render: bool) {
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
if first_render {
|
if first_render {
|
||||||
self.modal = Some(Modal::new(
|
self.modal = Some(Modal::new(
|
||||||
self.node_ref
|
self.node_ref
|
||||||
@@ -96,43 +97,42 @@ impl Component for DeleteGroup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
onclick={link.callback(|_| Msg::ClickedDeleteGroup)}>
|
||||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||||
</button>
|
</button>
|
||||||
{self.show_modal()}
|
{self.show_modal(ctx)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeleteGroup {
|
impl DeleteGroup {
|
||||||
fn show_modal(&self) -> Html {
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
class="modal fade"
|
||||||
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
|
id={"deleteGroupModal".to_string() + &ctx.props().group.id.to_string()}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-labelledby="deleteGroupModalLabel"
|
aria-labelledby="deleteGroupModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
ref=self.node_ref.clone()>
|
ref={self.node_ref.clone()}>
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -141,25 +141,29 @@ impl DeleteGroup {
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span>
|
<span>
|
||||||
{"Are you sure you want to delete group "}
|
{"Are you sure you want to delete group "}
|
||||||
<b>{&self.common.group.display_name}</b>{"?"}
|
<b>{&ctx.props().group.display_name}</b>{"?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
|
<i class="bi-x-circle me-2"></i>
|
||||||
{"Cancel"}
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
onclick={link.callback(|_| Msg::ConfirmDeleteGroup)}
|
||||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
172
app/src/components/delete_group_attribute.rs
Normal file
172
app/src/components/delete_group_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
modal::Modal,
|
||||||
|
};
|
||||||
|
use anyhow::{Error, Result};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/delete_group_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct DeleteGroupAttributeQuery;
|
||||||
|
|
||||||
|
pub struct DeleteGroupAttribute {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
node_ref: NodeRef,
|
||||||
|
modal: Option<Modal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||||
|
pub struct DeleteGroupAttributeProps {
|
||||||
|
pub attribute_name: String,
|
||||||
|
pub on_attribute_deleted: Callback<String>,
|
||||||
|
pub on_error: Callback<Error>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ClickedDeleteGroupAttribute,
|
||||||
|
ConfirmDeleteGroupAttribute,
|
||||||
|
DismissModal,
|
||||||
|
DeleteGroupAttributeResponse(Result<delete_group_attribute_query::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<DeleteGroupAttribute> for DeleteGroupAttribute {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ClickedDeleteGroupAttribute => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
|
}
|
||||||
|
Msg::ConfirmDeleteGroupAttribute => {
|
||||||
|
self.update(ctx, Msg::DismissModal);
|
||||||
|
self.common.call_graphql::<DeleteGroupAttributeQuery, _>(
|
||||||
|
ctx,
|
||||||
|
delete_group_attribute_query::Variables {
|
||||||
|
name: ctx.props().attribute_name.clone(),
|
||||||
|
},
|
||||||
|
Msg::DeleteGroupAttributeResponse,
|
||||||
|
"Error trying to delete group attribute",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Msg::DismissModal => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
|
}
|
||||||
|
Msg::DeleteGroupAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
ctx.props()
|
||||||
|
.on_attribute_deleted
|
||||||
|
.emit(ctx.props().attribute_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for DeleteGroupAttribute {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = DeleteGroupAttributeProps;
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
node_ref: NodeRef::default(),
|
||||||
|
modal: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
|
if first_render {
|
||||||
|
self.modal = Some(Modal::new(
|
||||||
|
self.node_ref
|
||||||
|
.cast::<web_sys::Element>()
|
||||||
|
.expect("Modal node is not an element"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
|
self,
|
||||||
|
ctx,
|
||||||
|
msg,
|
||||||
|
ctx.props().on_error.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|_| Msg::ClickedDeleteGroupAttribute)}>
|
||||||
|
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||||
|
</button>
|
||||||
|
{self.show_modal(ctx)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteGroupAttribute {
|
||||||
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="modal fade"
|
||||||
|
id={"deleteGroupAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="deleteGroupAttributeModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
ref={self.node_ref.clone()}>
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteGroupAttributeModalLabel">{"Delete group attribute?"}</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Close"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<span>
|
||||||
|
{"Are you sure you want to delete group attribute "}
|
||||||
|
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
|
<i class="bi-x-circle me-2"></i>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| Msg::ConfirmDeleteGroupAttribute)}
|
||||||
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,16 +36,21 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<DeleteUser> for DeleteUser {
|
impl CommonComponent<DeleteUser> for DeleteUser {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ClickedDeleteUser => {
|
Msg::ClickedDeleteUser => {
|
||||||
self.modal.as_ref().expect("modal not initialized").show();
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
}
|
}
|
||||||
Msg::ConfirmDeleteUser => {
|
Msg::ConfirmDeleteUser => {
|
||||||
self.update(Msg::DismissModal);
|
self.update(ctx, Msg::DismissModal);
|
||||||
self.common.call_graphql::<DeleteUserQuery, _>(
|
self.common.call_graphql::<DeleteUserQuery, _>(
|
||||||
|
ctx,
|
||||||
delete_user_query::Variables {
|
delete_user_query::Variables {
|
||||||
user: self.common.username.clone(),
|
user: ctx.props().username.clone(),
|
||||||
},
|
},
|
||||||
Msg::DeleteUserResponse,
|
Msg::DeleteUserResponse,
|
||||||
"Error trying to delete user",
|
"Error trying to delete user",
|
||||||
@@ -55,12 +60,10 @@ impl CommonComponent<DeleteUser> for DeleteUser {
|
|||||||
self.modal.as_ref().expect("modal not initialized").hide();
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
}
|
}
|
||||||
Msg::DeleteUserResponse(response) => {
|
Msg::DeleteUserResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
response?;
|
response?;
|
||||||
self.common
|
ctx.props()
|
||||||
.props
|
|
||||||
.on_user_deleted
|
.on_user_deleted
|
||||||
.emit(self.common.username.clone());
|
.emit(ctx.props().username.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -75,15 +78,15 @@ impl Component for DeleteUser {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = DeleteUserProps;
|
type Properties = DeleteUserProps;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
modal: None,
|
modal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered(&mut self, first_render: bool) {
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
if first_render {
|
if first_render {
|
||||||
self.modal = Some(Modal::new(
|
self.modal = Some(Modal::new(
|
||||||
self.node_ref
|
self.node_ref
|
||||||
@@ -93,44 +96,43 @@ impl Component for DeleteUser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
onclick={link.callback(|_| Msg::ClickedDeleteUser)}>
|
||||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||||
</button>
|
</button>
|
||||||
{self.show_modal()}
|
{self.show_modal(ctx)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeleteUser {
|
impl DeleteUser {
|
||||||
fn show_modal(&self) -> Html {
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
class="modal fade"
|
||||||
id="deleteUserModal".to_string() + &self.common.username
|
id={"deleteUserModal".to_string() + &ctx.props().username}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
//role="dialog"
|
//role="dialog"
|
||||||
aria-labelledby="deleteUserModalLabel"
|
aria-labelledby="deleteUserModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
ref=self.node_ref.clone()>
|
ref={self.node_ref.clone()}>
|
||||||
<div class="modal-dialog" /*role="document"*/>
|
<div class="modal-dialog" /*role="document"*/>
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -139,25 +141,29 @@ impl DeleteUser {
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span>
|
<span>
|
||||||
{"Are you sure you want to delete user "}
|
{"Are you sure you want to delete user "}
|
||||||
<b>{&self.common.username}</b>{"?"}
|
<b>{&ctx.props().username}</b>{"?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
{"Cancel"}
|
<i class="bi-x-circle me-2"></i>
|
||||||
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
onclick={link.callback(|_| Msg::ConfirmDeleteUser)}
|
||||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
172
app/src/components/delete_user_attribute.rs
Normal file
172
app/src/components/delete_user_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
modal::Modal,
|
||||||
|
};
|
||||||
|
use anyhow::{Error, Result};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/delete_user_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct DeleteUserAttributeQuery;
|
||||||
|
|
||||||
|
pub struct DeleteUserAttribute {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
node_ref: NodeRef,
|
||||||
|
modal: Option<Modal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||||
|
pub struct DeleteUserAttributeProps {
|
||||||
|
pub attribute_name: String,
|
||||||
|
pub on_attribute_deleted: Callback<String>,
|
||||||
|
pub on_error: Callback<Error>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ClickedDeleteUserAttribute,
|
||||||
|
ConfirmDeleteUserAttribute,
|
||||||
|
DismissModal,
|
||||||
|
DeleteUserAttributeResponse(Result<delete_user_attribute_query::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<DeleteUserAttribute> for DeleteUserAttribute {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ClickedDeleteUserAttribute => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
|
}
|
||||||
|
Msg::ConfirmDeleteUserAttribute => {
|
||||||
|
self.update(ctx, Msg::DismissModal);
|
||||||
|
self.common.call_graphql::<DeleteUserAttributeQuery, _>(
|
||||||
|
ctx,
|
||||||
|
delete_user_attribute_query::Variables {
|
||||||
|
name: ctx.props().attribute_name.clone(),
|
||||||
|
},
|
||||||
|
Msg::DeleteUserAttributeResponse,
|
||||||
|
"Error trying to delete user attribute",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Msg::DismissModal => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
|
}
|
||||||
|
Msg::DeleteUserAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
ctx.props()
|
||||||
|
.on_attribute_deleted
|
||||||
|
.emit(ctx.props().attribute_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for DeleteUserAttribute {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = DeleteUserAttributeProps;
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
node_ref: NodeRef::default(),
|
||||||
|
modal: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
|
if first_render {
|
||||||
|
self.modal = Some(Modal::new(
|
||||||
|
self.node_ref
|
||||||
|
.cast::<web_sys::Element>()
|
||||||
|
.expect("Modal node is not an element"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
|
self,
|
||||||
|
ctx,
|
||||||
|
msg,
|
||||||
|
ctx.props().on_error.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|_| Msg::ClickedDeleteUserAttribute)}>
|
||||||
|
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||||
|
</button>
|
||||||
|
{self.show_modal(ctx)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteUserAttribute {
|
||||||
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="modal fade"
|
||||||
|
id={"deleteUserAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="deleteUserAttributeModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
ref={self.node_ref.clone()}>
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteUserAttributeModalLabel">{"Delete user attribute?"}</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Close"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<span>
|
||||||
|
{"Are you sure you want to delete user attribute "}
|
||||||
|
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
|
<i class="bi-x-circle me-2"></i>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| Msg::ConfirmDeleteUserAttribute)}
|
||||||
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
app/src/components/form/attribute_input.rs
Normal file
190
app/src/components/form/attribute_input.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
use crate::{
|
||||||
|
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
|
||||||
|
infra::{schema::AttributeType, tooltip::Tooltip},
|
||||||
|
};
|
||||||
|
use web_sys::Element;
|
||||||
|
use yew::{
|
||||||
|
function_component, html, use_effect_with_deps, use_node_ref, virtual_dom::AttrValue,
|
||||||
|
Component, Context, Html, Properties,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct AttributeInputProps {
|
||||||
|
name: AttrValue,
|
||||||
|
attribute_type: AttributeType,
|
||||||
|
#[prop_or(None)]
|
||||||
|
value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AttributeInput)]
|
||||||
|
fn attribute_input(props: &AttributeInputProps) -> Html {
|
||||||
|
let input_type = match props.attribute_type {
|
||||||
|
AttributeType::String => "text",
|
||||||
|
AttributeType::Integer => "number",
|
||||||
|
AttributeType::DateTime => {
|
||||||
|
return html! {
|
||||||
|
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttributeType::Jpeg => {
|
||||||
|
return html! {
|
||||||
|
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<input
|
||||||
|
type={input_type}
|
||||||
|
name={props.name.clone()}
|
||||||
|
class="form-control"
|
||||||
|
value={props.value.clone()} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct AttributeLabelProps {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
#[function_component(AttributeLabel)]
|
||||||
|
fn attribute_label(props: &AttributeLabelProps) -> Html {
|
||||||
|
let tooltip_ref = use_node_ref();
|
||||||
|
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |tooltip_ref| {
|
||||||
|
Tooltip::new(
|
||||||
|
tooltip_ref
|
||||||
|
.cast::<Element>()
|
||||||
|
.expect("Tooltip element should exist"),
|
||||||
|
);
|
||||||
|
|| {}
|
||||||
|
},
|
||||||
|
tooltip_ref.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<label for={props.name.clone()}
|
||||||
|
class="form-label col-4 col-form-label"
|
||||||
|
>
|
||||||
|
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-link"
|
||||||
|
type="button"
|
||||||
|
data-bs-placement="right"
|
||||||
|
title={props.name.clone()}
|
||||||
|
ref={tooltip_ref}>
|
||||||
|
<i class="bi bi-info-circle" aria-label="Info" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct SingleAttributeInputProps {
|
||||||
|
pub name: String,
|
||||||
|
pub attribute_type: AttributeType,
|
||||||
|
#[prop_or(None)]
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(SingleAttributeInput)]
|
||||||
|
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<AttributeLabel name={props.name.clone()} />
|
||||||
|
<div class="col-8">
|
||||||
|
<AttributeInput
|
||||||
|
attribute_type={props.attribute_type.clone()}
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={props.value.clone()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ListAttributeInputProps {
|
||||||
|
pub name: String,
|
||||||
|
pub attribute_type: AttributeType,
|
||||||
|
#[prop_or(vec!())]
|
||||||
|
pub values: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ListAttributeInputMsg {
|
||||||
|
Remove(usize),
|
||||||
|
Append,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListAttributeInput {
|
||||||
|
indices: Vec<usize>,
|
||||||
|
next_index: usize,
|
||||||
|
values: Vec<String>,
|
||||||
|
}
|
||||||
|
impl Component for ListAttributeInput {
|
||||||
|
type Message = ListAttributeInputMsg;
|
||||||
|
type Properties = ListAttributeInputProps;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let values = ctx.props().values.clone();
|
||||||
|
Self {
|
||||||
|
indices: (0..values.len()).collect(),
|
||||||
|
next_index: values.len(),
|
||||||
|
values,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
match msg {
|
||||||
|
ListAttributeInputMsg::Remove(removed) => {
|
||||||
|
self.indices.retain_mut(|x| *x != removed);
|
||||||
|
}
|
||||||
|
ListAttributeInputMsg::Append => {
|
||||||
|
self.indices.push(self.next_index);
|
||||||
|
self.next_index += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||||
|
if ctx.props().values != self.values {
|
||||||
|
self.values.clone_from(&ctx.props().values);
|
||||||
|
self.indices = (0..self.values.len()).collect();
|
||||||
|
self.next_index = self.values.len();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let props = &ctx.props();
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<AttributeLabel name={props.name.clone()} />
|
||||||
|
<div class="col-8">
|
||||||
|
{self.indices.iter().map(|&i| html! {
|
||||||
|
<div class="input-group mb-2" key={i}>
|
||||||
|
<AttributeInput
|
||||||
|
attribute_type={props.attribute_type.clone()}
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={props.values.get(i).cloned().unwrap_or_default()} />
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
|
||||||
|
<i class="bi-x-circle-fill" aria-label="Remove value" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}).collect::<Html>()}
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Add value"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/src/components/form/checkbox.rs
Normal file
35
app/src/components/form/checkbox.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Properties};
|
||||||
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props<T: Model> {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub field_name: String,
|
||||||
|
pub form: Form<T>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
|
#[prop_or_else(Callback::noop)]
|
||||||
|
pub ontoggle: Callback<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CheckBox)]
|
||||||
|
pub fn checkbox<T: Model>(props: &Props<T>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for={props.field_name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{if props.required {
|
||||||
|
html!{<span class="text-danger">{"*"}</span>}
|
||||||
|
} else {html!{}}}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<yew_form::CheckBox<T>
|
||||||
|
form={&props.form}
|
||||||
|
field_name={props.field_name.clone()}
|
||||||
|
ontoggle={props.ontoggle.clone()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/src/components/form/date_input.rs
Normal file
49
app/src/components/form/date_input.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::{function_component, html, use_state, virtual_dom::AttrValue, Event, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct DateTimeInputProps {
|
||||||
|
pub name: AttrValue,
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(DateTimeInput)]
|
||||||
|
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
|
||||||
|
let value = use_state(|| {
|
||||||
|
props
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
|
||||||
|
});
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
step="1"
|
||||||
|
class="form-control"
|
||||||
|
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
|
||||||
|
onchange={move |e: Event| {
|
||||||
|
let string_val =
|
||||||
|
e.target()
|
||||||
|
.expect("Event should have target")
|
||||||
|
.unchecked_into::<HtmlInputElement>()
|
||||||
|
.value();
|
||||||
|
value.set(
|
||||||
|
NaiveDateTime::from_str(&string_val)
|
||||||
|
.ok()
|
||||||
|
.map(|x| DateTime::from_naive_utc_and_offset(x, Utc))
|
||||||
|
)
|
||||||
|
}} />
|
||||||
|
<span class="input-group-text">{"UTC"}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/src/components/form/field.rs
Normal file
48
app/src/components/form/field.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties};
|
||||||
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props<T: Model> {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub field_name: String,
|
||||||
|
pub form: Form<T>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
|
#[prop_or(String::from("text"))]
|
||||||
|
pub input_type: String,
|
||||||
|
// If not present, will default to field_name
|
||||||
|
#[prop_or(None)]
|
||||||
|
pub autocomplete: Option<String>,
|
||||||
|
#[prop_or_else(Callback::noop)]
|
||||||
|
pub oninput: Callback<InputEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Field)]
|
||||||
|
pub fn field<T: Model>(props: &Props<T>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for={props.field_name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{if props.required {
|
||||||
|
html!{<span class="text-danger">{"*"}</span>}
|
||||||
|
} else {html!{}}}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<yew_form::Field<T>
|
||||||
|
form={&props.form}
|
||||||
|
field_name={props.field_name.clone()}
|
||||||
|
input_type={props.input_type.clone()}
|
||||||
|
class="form-control"
|
||||||
|
class_invalid="is-invalid has-error"
|
||||||
|
class_valid="has-success"
|
||||||
|
autocomplete={props.autocomplete.clone().unwrap_or(props.field_name.clone())}
|
||||||
|
oninput={&props.oninput} />
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
{&props.form.field_message(&props.field_name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
238
app/src/components/form/file_input.rs
Normal file
238
app/src/components/form/file_input.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
use std::{fmt::Display, str::FromStr};
|
||||||
|
|
||||||
|
use anyhow::{bail, Error, Ok, Result};
|
||||||
|
use gloo_file::{
|
||||||
|
callbacks::{read_as_bytes, FileReader},
|
||||||
|
File,
|
||||||
|
};
|
||||||
|
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||||
|
use yew::Properties;
|
||||||
|
use yew::{prelude::*, virtual_dom::AttrValue};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct JsFile {
|
||||||
|
file: Option<File>,
|
||||||
|
contents: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for JsFile {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for JsFile {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self> {
|
||||||
|
if s.is_empty() {
|
||||||
|
Ok(JsFile::default())
|
||||||
|
} else {
|
||||||
|
bail!("Building file from non-empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_base64(file: &JsFile) -> Result<String> {
|
||||||
|
match file {
|
||||||
|
JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: None,
|
||||||
|
} => Ok(String::new()),
|
||||||
|
JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: None,
|
||||||
|
} => bail!("Image file hasn't finished loading, try again"),
|
||||||
|
JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: Some(data),
|
||||||
|
} => {
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
|
bail!("Chosen image is not a valid JPEG");
|
||||||
|
}
|
||||||
|
Ok(base64::encode(data))
|
||||||
|
}
|
||||||
|
JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: Some(data),
|
||||||
|
} => Ok(base64::encode(data)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||||
|
pub struct JpegFileInput {
|
||||||
|
// None means that the avatar hasn't changed.
|
||||||
|
avatar: Option<JsFile>,
|
||||||
|
reader: Option<FileReader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
Update,
|
||||||
|
/// A new file was selected.
|
||||||
|
FileSelected(File),
|
||||||
|
/// The "Clear" button for the avatar was clicked.
|
||||||
|
ClearClicked,
|
||||||
|
/// A picked file finished loading.
|
||||||
|
FileLoaded(String, Result<Vec<u8>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub name: AttrValue,
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for JpegFileInput {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
avatar: Some(JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: ctx
|
||||||
|
.props()
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| base64::decode(x).ok()),
|
||||||
|
}),
|
||||||
|
reader: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||||
|
self.avatar = Some(JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: ctx
|
||||||
|
.props()
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| base64::decode(x).ok()),
|
||||||
|
});
|
||||||
|
self.reader = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => true,
|
||||||
|
Msg::FileSelected(new_avatar) => {
|
||||||
|
if self
|
||||||
|
.avatar
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||||
|
!= Some(new_avatar.name())
|
||||||
|
{
|
||||||
|
let file_name = new_avatar.name();
|
||||||
|
let link = ctx.link().clone();
|
||||||
|
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||||
|
link.send_message(Msg::FileLoaded(
|
||||||
|
file_name,
|
||||||
|
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||||
|
))
|
||||||
|
}));
|
||||||
|
self.avatar = Some(JsFile {
|
||||||
|
file: Some(new_avatar),
|
||||||
|
contents: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Msg::ClearClicked => {
|
||||||
|
self.avatar = Some(JsFile::default());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Msg::FileLoaded(file_name, data) => {
|
||||||
|
if let Some(avatar) = &mut self.avatar {
|
||||||
|
if let Some(file) = &avatar.file {
|
||||||
|
if file.name() == file_name {
|
||||||
|
if let Result::Ok(data) = data {
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
|
// Clear the selection.
|
||||||
|
self.avatar = Some(JsFile::default());
|
||||||
|
// TODO: bail!("Chosen image is not a valid JPEG");
|
||||||
|
} else {
|
||||||
|
avatar.contents = Some(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reader = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
|
||||||
|
let avatar_string = match &self.avatar {
|
||||||
|
Some(avatar) => {
|
||||||
|
let avatar_base64 = to_base64(avatar);
|
||||||
|
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-5">
|
||||||
|
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatarInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg"
|
||||||
|
oninput={link.callback(|e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
Self::upload_files(input.files())
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary col-auto"
|
||||||
|
id="avatarClear"
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| {Msg::ClearClicked})}>
|
||||||
|
{"Clear"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
{
|
||||||
|
if !avatar_string.is_empty() {
|
||||||
|
html!{
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||||
|
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||||
|
alt="Avatar" />
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JpegFileInput {
|
||||||
|
fn upload_files(files: Option<FileList>) -> Msg {
|
||||||
|
match files {
|
||||||
|
Some(files) if files.length() > 0 => {
|
||||||
|
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||||
|
}
|
||||||
|
Some(_) | None => Msg::Update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||||
|
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||||
|
.decode()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
8
app/src/components/form/mod.rs
Normal file
8
app/src/components/form/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod attribute_input;
|
||||||
|
pub mod checkbox;
|
||||||
|
pub mod date_input;
|
||||||
|
pub mod field;
|
||||||
|
pub mod file_input;
|
||||||
|
pub mod select;
|
||||||
|
pub mod static_value;
|
||||||
|
pub mod submit;
|
||||||
46
app/src/components/form/select.rs
Normal file
46
app/src/components/form/select.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use yew::{
|
||||||
|
function_component, html, virtual_dom::AttrValue, Callback, Children, InputEvent, Properties,
|
||||||
|
};
|
||||||
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props<T: Model> {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub field_name: String,
|
||||||
|
pub form: Form<T>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
|
#[prop_or_else(Callback::noop)]
|
||||||
|
pub oninput: Callback<InputEvent>,
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Select)]
|
||||||
|
pub fn select<T: Model>(props: &Props<T>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for={props.field_name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{if props.required {
|
||||||
|
html!{<span class="text-danger">{"*"}</span>}
|
||||||
|
} else {html!{}}}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<yew_form::Select<T>
|
||||||
|
form={&props.form}
|
||||||
|
class="form-control"
|
||||||
|
class_invalid="is-invalid has-error"
|
||||||
|
class_valid="has-success"
|
||||||
|
field_name={props.field_name.clone()}
|
||||||
|
oninput={&props.oninput} >
|
||||||
|
{for props.children.iter()}
|
||||||
|
</yew_form::Select<T>>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
{&props.form.field_message(&props.field_name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/src/components/form/static_value.rs
Normal file
26
app/src/components/form/static_value.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Children, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub id: AttrValue,
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(StaticValue)]
|
||||||
|
pub fn static_value(props: &Props) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for={props.id.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id={props.id.clone()} class="form-control-static">
|
||||||
|
{for props.children.iter()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/src/components/form/submit.rs
Normal file
30
app/src/components/form/submit.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Children, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub disabled: bool,
|
||||||
|
pub onclick: Callback<MouseEvent>,
|
||||||
|
// Additional elements to insert after the button, in the same div
|
||||||
|
#[prop_or_default]
|
||||||
|
pub children: Children,
|
||||||
|
#[prop_or(AttrValue::from("Submit"))]
|
||||||
|
pub text: AttrValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Submit)]
|
||||||
|
pub fn submit(props: &Props) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-group row justify-content-center">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary col-auto col-form-label"
|
||||||
|
type="submit"
|
||||||
|
disabled={props.disabled}
|
||||||
|
onclick={&props.onclick}>
|
||||||
|
<i class="bi-save me-2"></i>
|
||||||
|
{props.text.clone()}
|
||||||
|
</button>
|
||||||
|
{for props.children.iter()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
add_group_member::{self, AddGroupMemberComponent},
|
add_group_member::{self, AddGroupMemberComponent},
|
||||||
|
group_details_form::GroupDetailsForm,
|
||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
},
|
},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::GraphQlAttributeSchema,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
@@ -22,12 +27,28 @@ pub struct GetGroupDetails;
|
|||||||
pub type Group = get_group_details::GetGroupDetailsGroup;
|
pub type Group = get_group_details::GetGroupDetailsGroup;
|
||||||
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
||||||
pub type AddGroupMemberUser = add_group_member::User;
|
pub type AddGroupMemberUser = add_group_member::User;
|
||||||
|
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
|
||||||
|
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
|
||||||
|
pub type AttributeType = get_group_details::AttributeType;
|
||||||
|
|
||||||
|
convert_attribute_type!(AttributeType);
|
||||||
|
|
||||||
|
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||||
|
fn from(attr: &AttributeSchema) -> Self {
|
||||||
|
Self {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
is_list: attr.is_list,
|
||||||
|
is_readonly: attr.is_readonly,
|
||||||
|
is_editable: attr.is_editable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GroupDetails {
|
pub struct GroupDetails {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
/// 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_and_schema: Option<(Group, Vec<AttributeSchema>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State machine describing the possible transitions of the component state.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -38,18 +59,21 @@ pub enum Msg {
|
|||||||
OnError(Error),
|
OnError(Error),
|
||||||
OnUserAddedToGroup(AddGroupMemberUser),
|
OnUserAddedToGroup(AddGroupMemberUser),
|
||||||
OnUserRemovedFromGroup((String, i64)),
|
OnUserRemovedFromGroup((String, i64)),
|
||||||
|
DisplayNameUpdated,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub group_id: i64,
|
pub group_id: i64,
|
||||||
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupDetails {
|
impl GroupDetails {
|
||||||
fn get_group_details(&mut self) {
|
fn get_group_details(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<GetGroupDetails, _>(
|
self.common.call_graphql::<GetGroupDetails, _>(
|
||||||
|
ctx,
|
||||||
get_group_details::Variables {
|
get_group_details::Variables {
|
||||||
id: self.common.group_id,
|
id: ctx.props().group_id,
|
||||||
},
|
},
|
||||||
Msg::GroupDetailsResponse,
|
Msg::GroupDetailsResponse,
|
||||||
"Error trying to fetch group details",
|
"Error trying to fetch group details",
|
||||||
@@ -68,63 +92,39 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_details(&self, g: &Group) -> Html {
|
fn view_details(&self, ctx: &Context<Self>, g: &Group, schema: Vec<AttributeSchema>) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{g.display_name.to_string()}</h3>
|
<h3>{g.display_name.to_string()}</h3>
|
||||||
<div class="py-3">
|
<GroupDetailsForm
|
||||||
<form class="form">
|
group={g.clone()}
|
||||||
<div class="form-group row mb-3">
|
group_attributes_schema={schema}
|
||||||
<label for="displayName"
|
is_admin={ctx.props().is_admin}
|
||||||
class="form-label col-4 col-form-label">
|
on_display_name_updated={ctx.link().callback(|_| Msg::DisplayNameUpdated)}
|
||||||
{"Group: "}
|
/>
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="creationDate"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Creation date: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<span id="creationDate" class="form-constrol-static">{g.creation_date.date().naive_local()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="uuid"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"UUID: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_user_list(&self, g: &Group) -> Html {
|
fn view_user_list(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
let make_user_row = |user: &User| {
|
let make_user_row = |user: &User| {
|
||||||
let user_id = user.id.clone();
|
let user_id = user.id.clone();
|
||||||
let display_name = user.display_name.clone();
|
let display_name = user.display_name.clone();
|
||||||
html! {
|
html! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::UserDetails(user_id.clone())>
|
<Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
|
||||||
{user_id.clone()}
|
{user_id.clone()}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>{display_name}</td>
|
<td>{display_name}</td>
|
||||||
<td>
|
<td>
|
||||||
<RemoveUserFromGroupComponent
|
<RemoveUserFromGroupComponent
|
||||||
username=user_id
|
username={user_id}
|
||||||
group_id=g.id
|
group_id={g.id}
|
||||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ impl GroupDetails {
|
|||||||
<>
|
<>
|
||||||
<h5 class="fw-bold">{"Members"}</h5>
|
<h5 class="fw-bold">{"Members"}</h5>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr key="headerRow">
|
<tr key="headerRow">
|
||||||
<th>{"User Id"}</th>
|
<th>{"User Id"}</th>
|
||||||
@@ -145,7 +145,7 @@ impl GroupDetails {
|
|||||||
{if g.users.is_empty() {
|
{if g.users.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
<tr key="EmptyRow">
|
<tr key="EmptyRow">
|
||||||
<td>{"No members"}</td>
|
<td>{"There are no users in this group."}</td>
|
||||||
<td/>
|
<td/>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,8 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_add_user_button(&self, g: &Group) -> Html {
|
fn view_add_user_button(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
let users: Vec<_> = g
|
let users: Vec<_> = g
|
||||||
.users
|
.users
|
||||||
.iter()
|
.iter()
|
||||||
@@ -170,38 +171,47 @@ impl GroupDetails {
|
|||||||
.collect();
|
.collect();
|
||||||
html! {
|
html! {
|
||||||
<AddGroupMemberComponent
|
<AddGroupMemberComponent
|
||||||
group_id=g.id
|
group_id={g.id}
|
||||||
users=users
|
users={users}
|
||||||
on_error=self.common.callback(Msg::OnError)
|
on_error={link.callback(Msg::OnError)}
|
||||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::GroupDetailsResponse(response) => match response {
|
Msg::GroupDetailsResponse(response) => match response {
|
||||||
Ok(group) => self.group = Some(group.group),
|
Ok(group) => {
|
||||||
|
self.group_and_schema =
|
||||||
|
Some((group.group, group.schema.group_schema.attributes))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.group = None;
|
self.group_and_schema = None;
|
||||||
bail!("Error getting user details: {}", e);
|
bail!("Error getting user details: {}", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Msg::OnError(e) => return Err(e),
|
Msg::OnError(e) => return Err(e),
|
||||||
Msg::OnUserAddedToGroup(user) => {
|
Msg::OnUserAddedToGroup(user) => {
|
||||||
self.group.as_mut().unwrap().users.push(User {
|
self.group_and_schema.as_mut().unwrap().0.users.push(User {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||||
self.group
|
self.group_and_schema
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.0
|
||||||
.users
|
.users
|
||||||
.retain(|u| u.id != user_id);
|
.retain(|u| u.id != user_id);
|
||||||
}
|
}
|
||||||
|
Msg::DisplayNameUpdated => self.get_group_details(ctx),
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -215,33 +225,29 @@ impl Component for GroupDetails {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
group: None,
|
group_and_schema: None,
|
||||||
};
|
};
|
||||||
table.get_group_details();
|
table.get_group_details(ctx);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
match (&self.group_and_schema, &self.common.error) {
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
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((group, schema)), error) => {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_details(u)}
|
{self.view_details(ctx, group, schema.clone())}
|
||||||
{self.view_user_list(u)}
|
{self.view_user_list(ctx, group)}
|
||||||
{self.view_add_user_button(u)}
|
{self.view_add_user_button(ctx, group)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
272
app/src/components/group_details_form.rs
Normal file
272
app/src/components/group_details_form.rs
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
form::{
|
||||||
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
|
static_value::StaticValue,
|
||||||
|
submit::Submit,
|
||||||
|
},
|
||||||
|
group_details::{Attribute, AttributeSchema, Group},
|
||||||
|
},
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{Ok, Result};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
/// The GraphQL query sent to the server to update the group details.
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/update_group.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
variables_derives = "Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct UpdateGroup;
|
||||||
|
|
||||||
|
/// A [yew::Component] to display the group details, with a form allowing to edit them.
|
||||||
|
pub struct GroupDetailsForm {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
/// True if we just successfully updated the group, to display a success message.
|
||||||
|
just_updated: bool,
|
||||||
|
updated_group_name: bool,
|
||||||
|
group: Group,
|
||||||
|
form_ref: NodeRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
/// A form field changed.
|
||||||
|
Update,
|
||||||
|
/// The "Submit" button was clicked.
|
||||||
|
SubmitClicked,
|
||||||
|
/// We got the response from the server about our update message.
|
||||||
|
GroupUpdated(Result<update_group::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
/// The current group details.
|
||||||
|
pub group: Group,
|
||||||
|
pub group_attributes_schema: Vec<AttributeSchema>,
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub on_display_name_updated: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<GroupDetailsForm> for GroupDetailsForm {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => Ok(true),
|
||||||
|
Msg::SubmitClicked => self.submit_group_update_form(ctx),
|
||||||
|
Msg::GroupUpdated(Err(e)) => Err(e),
|
||||||
|
Msg::GroupUpdated(Result::Ok(_)) => {
|
||||||
|
self.just_updated = true;
|
||||||
|
if self.updated_group_name {
|
||||||
|
self.updated_group_name = false;
|
||||||
|
ctx.props().on_display_name_updated.emit(());
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for GroupDetailsForm {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
just_updated: false,
|
||||||
|
updated_group_name: false,
|
||||||
|
group: ctx.props().group.clone(),
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
self.just_updated = false;
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
|
||||||
|
let can_edit =
|
||||||
|
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||||
|
let display_field = |a: &AttributeSchema| {
|
||||||
|
if can_edit(a) {
|
||||||
|
get_custom_attribute_input(a, &self.group.attributes)
|
||||||
|
} else {
|
||||||
|
get_custom_attribute_static(a, &self.group.attributes)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<div class="py-3">
|
||||||
|
<form
|
||||||
|
class="form"
|
||||||
|
ref={self.form_ref.clone()}>
|
||||||
|
<StaticValue label="Group ID" id="groupId">
|
||||||
|
<i>{&self.group.id}</i>
|
||||||
|
</StaticValue>
|
||||||
|
{
|
||||||
|
ctx
|
||||||
|
.props()
|
||||||
|
.group_attributes_schema
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.is_hardcoded && a.name != "group_id")
|
||||||
|
.map(display_field)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
ctx
|
||||||
|
.props()
|
||||||
|
.group_attributes_schema
|
||||||
|
.iter()
|
||||||
|
.filter(|a| !a.is_hardcoded)
|
||||||
|
.map(display_field)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
<Submit
|
||||||
|
text="Save changes"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||||
|
</form>
|
||||||
|
{
|
||||||
|
if let Some(e) = &self.common.error {
|
||||||
|
html! {
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
<div hidden={!self.just_updated}>
|
||||||
|
<div class="alert alert-success mt-4">{"Group successfully updated!"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(
|
||||||
|
attribute_schema: &AttributeSchema,
|
||||||
|
group_attributes: &[Attribute],
|
||||||
|
) -> Html {
|
||||||
|
let values = group_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == attribute_schema.name)
|
||||||
|
.map(|attribute| attribute.value.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if attribute_schema.is_list {
|
||||||
|
html! {
|
||||||
|
<ListAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<SingleAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
value={values.first().cloned().unwrap_or_default()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_static(
|
||||||
|
attribute_schema: &AttributeSchema,
|
||||||
|
group_attributes: &[Attribute],
|
||||||
|
) -> Html {
|
||||||
|
let values = group_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == attribute_schema.name)
|
||||||
|
.map(|attribute| attribute.value.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
html! {
|
||||||
|
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||||
|
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||||
|
</StaticValue>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupDetailsForm {
|
||||||
|
fn submit_group_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
|
let mut all_values = read_all_form_attributes(
|
||||||
|
ctx.props().group_attributes_schema.iter(),
|
||||||
|
&self.form_ref,
|
||||||
|
IsAdmin(ctx.props().is_admin),
|
||||||
|
EmailIsRequired(false),
|
||||||
|
)?;
|
||||||
|
let base_attributes = &self.group.attributes;
|
||||||
|
all_values.retain(|a| {
|
||||||
|
let base_val = base_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|base_val| base_val.name == a.name);
|
||||||
|
base_val
|
||||||
|
.map(|v| v.value != a.values)
|
||||||
|
.unwrap_or(!a.values.is_empty())
|
||||||
|
});
|
||||||
|
if all_values.iter().any(|a| a.name == "display_name") {
|
||||||
|
self.updated_group_name = true;
|
||||||
|
}
|
||||||
|
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||||
|
};
|
||||||
|
let insert_attributes: Option<Vec<update_group::AttributeValueInput>> =
|
||||||
|
if remove_attributes.is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| !a.values.is_empty())
|
||||||
|
.map(
|
||||||
|
|AttributeValue { name, values }| update_group::AttributeValueInput {
|
||||||
|
name,
|
||||||
|
value: values,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut group_input = update_group::UpdateGroupInput {
|
||||||
|
id: self.group.id,
|
||||||
|
displayName: None,
|
||||||
|
removeAttributes: None,
|
||||||
|
insertAttributes: None,
|
||||||
|
};
|
||||||
|
let default_group_input = group_input.clone();
|
||||||
|
group_input.removeAttributes = remove_attributes;
|
||||||
|
group_input.insertAttributes = insert_attributes;
|
||||||
|
// Nothing changed.
|
||||||
|
if group_input == default_group_input {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let req = update_group::Variables { group: group_input };
|
||||||
|
self.common.call_graphql::<UpdateGroup, _>(
|
||||||
|
ctx,
|
||||||
|
req,
|
||||||
|
Msg::GroupUpdated,
|
||||||
|
"Error trying to update group",
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
198
app/src/components/group_schema_table.rs
Normal file
198
app/src/components/group_schema_table.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
delete_group_attribute::DeleteGroupAttribute,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, Error, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_group_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetGroupAttributesSchema;
|
||||||
|
|
||||||
|
use get_group_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute =
|
||||||
|
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub hardcoded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GroupSchemaTable {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
attributes: Option<Vec<Attribute>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
|
OnAttributeDeleted(String),
|
||||||
|
OnError(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<GroupSchemaTable> for GroupSchemaTable {
|
||||||
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes =
|
||||||
|
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::OnError(e) => Err(e),
|
||||||
|
Msg::OnAttributeDeleted(attribute_name) => {
|
||||||
|
match self.attributes {
|
||||||
|
None => {
|
||||||
|
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||||
|
Err(anyhow!("invalid state"))
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
self.attributes
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|a| a.name != attribute_name);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for GroupSchemaTable {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let mut table = GroupSchemaTable {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
attributes: None,
|
||||||
|
};
|
||||||
|
table.common.call_graphql::<GetGroupAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_group_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch group schema",
|
||||||
|
);
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
{self.view_attributes(ctx)}
|
||||||
|
{self.view_errors()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupSchemaTable {
|
||||||
|
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
let make_table = |attributes: &Vec<Attribute>| {
|
||||||
|
html! {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{"Attribute name"}</th>
|
||||||
|
<th>{"Type"}</th>
|
||||||
|
<th>{"Visible"}</th>
|
||||||
|
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match &self.attributes {
|
||||||
|
None => html! {{"Loading..."}},
|
||||||
|
Some(attributes) => {
|
||||||
|
let mut attributes = attributes.clone();
|
||||||
|
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||||
|
make_table(&attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||||
|
let checkmark = html! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||||
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||||
|
</svg>
|
||||||
|
};
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
html! {
|
||||||
|
<tr key={attribute.name.clone()}>
|
||||||
|
<td>{&attribute.name}</td>
|
||||||
|
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||||
|
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||||
|
{
|
||||||
|
if hardcoded {
|
||||||
|
html!{}
|
||||||
|
} else {
|
||||||
|
html!{
|
||||||
|
<td>
|
||||||
|
<DeleteGroupAttribute
|
||||||
|
attribute_name={attribute.name.clone()}
|
||||||
|
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
|
||||||
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_errors(&self) -> Html {
|
||||||
|
match &self.common.error {
|
||||||
|
None => html! {},
|
||||||
|
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ListGroupSchema)]
|
||||||
|
pub fn list_group_schema() -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<GroupSchemaTable hardcoded={true} />
|
||||||
|
<GroupSchemaTable hardcoded={false} />
|
||||||
|
<Link classes="btn btn-primary" to={AppRoute::CreateGroupAttribute}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Create an attribute"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<GroupTable> for GroupTable {
|
impl CommonComponent<GroupTable> for GroupTable {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ListGroupsResponse(groups) => {
|
Msg::ListGroupsResponse(groups) => {
|
||||||
self.groups = Some(groups?.groups.into_iter().collect());
|
self.groups = Some(groups?.groups.into_iter().collect());
|
||||||
@@ -58,12 +58,13 @@ impl Component for GroupTable {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = GroupTable {
|
let mut table = GroupTable {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
groups: None,
|
groups: None,
|
||||||
};
|
};
|
||||||
table.common.call_graphql::<GetGroupList, _>(
|
table.common.call_graphql::<GetGroupList, _>(
|
||||||
|
ctx,
|
||||||
get_group_list::Variables {},
|
get_group_list::Variables {},
|
||||||
Msg::ListGroupsResponse,
|
Msg::ListGroupsResponse,
|
||||||
"Error trying to fetch groups",
|
"Error trying to fetch groups",
|
||||||
@@ -71,18 +72,14 @@ impl Component for GroupTable {
|
|||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_groups()}
|
{self.view_groups(ctx)}
|
||||||
{self.view_errors()}
|
{self.view_errors()}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -90,11 +87,11 @@ impl Component for GroupTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GroupTable {
|
impl GroupTable {
|
||||||
fn view_groups(&self) -> Html {
|
fn view_groups(&self, ctx: &Context<Self>) -> Html {
|
||||||
let make_table = |groups: &Vec<Group>| {
|
let make_table = |groups: &Vec<Group>| {
|
||||||
html! {
|
html! {
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{"Group name"}</th>
|
<th>{"Group name"}</th>
|
||||||
@@ -103,7 +100,7 @@ impl GroupTable {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{groups.iter().map(|u| self.view_group(u)).collect::<Vec<_>>()}
|
{groups.iter().map(|u| self.view_group(ctx, u)).collect::<Vec<_>>()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,22 +112,23 @@ impl GroupTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_group(&self, group: &Group) -> Html {
|
fn view_group(&self, ctx: &Context<Self>, group: &Group) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<tr key=group.id>
|
<tr key={group.id}>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::GroupDetails(group.id)>
|
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||||
{&group.display_name}
|
{&group.display_name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{&group.creation_date.date().naive_local()}
|
{&group.creation_date.naive_local().date()}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<DeleteGroup
|
<DeleteGroup
|
||||||
group=group.clone()
|
group={group.clone()}
|
||||||
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
on_group_deleted={link.callback(Msg::OnGroupDeleted)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::{
|
||||||
|
form::submit::Submit,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use gloo_console::error;
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::{prelude::*, services::ConsoleService};
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ pub struct FormModel {
|
|||||||
#[derive(Clone, PartialEq, Properties)]
|
#[derive(Clone, PartialEq, Properties)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub on_logged_in: Callback<(String, bool)>,
|
pub on_logged_in: Callback<(String, bool)>,
|
||||||
|
pub password_reset_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
@@ -46,7 +51,12 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<LoginForm> for LoginForm {
|
impl CommonComponent<LoginForm> for LoginForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use anyhow::Context;
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
@@ -59,13 +69,13 @@ impl CommonComponent<LoginForm> for LoginForm {
|
|||||||
opaque::client::login::start_login(&password, &mut rng)
|
opaque::client::login::start_login(&password, &mut rng)
|
||||||
.context("Could not initialize login")?;
|
.context("Could not initialize login")?;
|
||||||
let req = login::ClientLoginStartRequest {
|
let req = login::ClientLoginStartRequest {
|
||||||
username,
|
username: username.into(),
|
||||||
login_start_request: message,
|
login_start_request: message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common
|
||||||
.call_backend(HostService::login_start, req, move |r| {
|
.call_backend(ctx, HostService::login_start(req), move |r| {
|
||||||
Msg::AuthenticationStartResponse((state, r))
|
Msg::AuthenticationStartResponse((state, r))
|
||||||
})?;
|
});
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
Msg::AuthenticationStartResponse((login_start, res)) => {
|
||||||
@@ -76,9 +86,8 @@ impl CommonComponent<LoginForm> for LoginForm {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Common error, we want to print a full error to the console but only a
|
// Common error, we want to print a full error to the console but only a
|
||||||
// simple one to the user.
|
// simple one to the user.
|
||||||
ConsoleService::error(&format!("Invalid username or password: {}", e));
|
error!(&format!("Invalid username or password: {}", e));
|
||||||
self.common.error = Some(anyhow!("Invalid username or password"));
|
self.common.error = Some(anyhow!("Invalid username or password"));
|
||||||
self.common.cancel_task();
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
@@ -88,24 +97,22 @@ impl CommonComponent<LoginForm> for LoginForm {
|
|||||||
credential_finalization: login_finish.message,
|
credential_finalization: login_finish.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::login_finish,
|
ctx,
|
||||||
req,
|
HostService::login_finish(req),
|
||||||
Msg::AuthenticationFinishResponse,
|
Msg::AuthenticationFinishResponse,
|
||||||
)?;
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationFinishResponse(user_info) => {
|
Msg::AuthenticationFinishResponse(user_info) => {
|
||||||
self.common.cancel_task();
|
ctx.props()
|
||||||
self.common
|
|
||||||
.on_logged_in
|
.on_logged_in
|
||||||
.emit(user_info.context("Could not log in")?);
|
.emit(user_info.context("Could not log in")?);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationRefreshResponse(user_info) => {
|
Msg::AuthenticationRefreshResponse(user_info) => {
|
||||||
self.refreshing = false;
|
self.refreshing = false;
|
||||||
self.common.cancel_task();
|
|
||||||
if let Ok(user_info) = user_info {
|
if let Ok(user_info) = user_info {
|
||||||
self.common.on_logged_in.emit(user_info);
|
ctx.props().on_logged_in.emit(user_info);
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -121,32 +128,28 @@ impl Component for LoginForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut app = LoginForm {
|
let mut app = LoginForm {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: Form::<FormModel>::new(FormModel::default()),
|
form: Form::<FormModel>::new(FormModel::default()),
|
||||||
refreshing: true,
|
refreshing: true,
|
||||||
};
|
};
|
||||||
if let Err(e) =
|
app.common.call_backend(
|
||||||
app.common
|
ctx,
|
||||||
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse)
|
HostService::refresh(),
|
||||||
{
|
Msg::AuthenticationRefreshResponse,
|
||||||
ConsoleService::debug(&format!("Could not refresh auth: {}", e));
|
);
|
||||||
app.refreshing = false;
|
|
||||||
}
|
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<FormModel>;
|
type Field = yew_form::Field<FormModel>;
|
||||||
|
let password_reset_enabled = ctx.props().password_reset_enabled;
|
||||||
|
let link = &ctx.link();
|
||||||
if self.refreshing {
|
if self.refreshing {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
@@ -155,61 +158,62 @@ impl Component for LoginForm {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<form
|
<form class="form center-block col-sm-4 col-offset-4">
|
||||||
class="form center-block col-sm-4 col-offset-4">
|
<div class="input-group">
|
||||||
<div class="input-group">
|
<div class="input-group-prepend">
|
||||||
<div class="input-group-prepend">
|
<span class="input-group-text">
|
||||||
<span class="input-group-text">
|
<i class="bi-person-fill"/>
|
||||||
<i class="bi-person-fill"/>
|
</span>
|
||||||
</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>
|
</div>
|
||||||
<div class="input-group">
|
<Field
|
||||||
<div class="input-group-prepend">
|
class="form-control"
|
||||||
<span class="input-group-text">
|
class_invalid="is-invalid has-error"
|
||||||
<i class="bi-lock-fill"/>
|
class_valid="has-success"
|
||||||
</span>
|
form={&self.form}
|
||||||
</div>
|
field_name="username"
|
||||||
<Field
|
placeholder="Username"
|
||||||
class="form-control"
|
autocomplete="username"
|
||||||
class_invalid="is-invalid has-error"
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
class_valid="has-success"
|
</div>
|
||||||
form=&self.form
|
<div class="input-group">
|
||||||
field_name="password"
|
<div class="input-group-prepend">
|
||||||
input_type="password"
|
<span class="input-group-text">
|
||||||
placeholder="Password"
|
<i class="bi-lock-fill"/>
|
||||||
autocomplete="current-password" />
|
</span>
|
||||||
</div>
|
|
||||||
<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})>
|
|
||||||
{"Login"}
|
|
||||||
</button>
|
|
||||||
<NavButton
|
|
||||||
classes="btn-link btn"
|
|
||||||
disabled=self.common.is_task_running()
|
|
||||||
route=AppRoute::StartResetPassword>
|
|
||||||
{"Forgot your password?"}
|
|
||||||
</NavButton>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
{ if let Some(e) = &self.common.error {
|
|
||||||
html! { e.to_string() }
|
|
||||||
} else { html! {} }
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Field
|
||||||
|
class="form-control"
|
||||||
|
class_invalid="is-invalid has-error"
|
||||||
|
class_valid="has-success"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="password"
|
||||||
|
input_type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<Submit
|
||||||
|
text="Login"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||||
|
{ if password_reset_enabled {
|
||||||
|
html! {
|
||||||
|
<Link
|
||||||
|
classes="btn-link btn"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
to={AppRoute::StartResetPassword}>
|
||||||
|
{"Forgot your password?"}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html!{}
|
||||||
|
}}
|
||||||
|
</Submit>
|
||||||
|
<div class="form-group">
|
||||||
|
{ if let Some(e) = &self.common.error {
|
||||||
|
html! { e.to_string() }
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,20 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<LogoutButton> for LogoutButton {
|
impl CommonComponent<LogoutButton> for LogoutButton {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::LogoutRequested => {
|
Msg::LogoutRequested => {
|
||||||
self.common
|
self.common
|
||||||
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
|
.call_backend(ctx, HostService::logout(), Msg::LogoutCompleted);
|
||||||
}
|
}
|
||||||
Msg::LogoutCompleted(res) => {
|
Msg::LogoutCompleted(res) => {
|
||||||
res?;
|
res?;
|
||||||
delete_cookie("user_id")?;
|
delete_cookie("user_id")?;
|
||||||
self.common.on_logged_out.emit(());
|
ctx.props().on_logged_out.emit(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
@@ -45,25 +49,22 @@ impl Component for LogoutButton {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
LogoutButton {
|
LogoutButton {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
onclick={link.callback(|_| Msg::LogoutRequested)}>
|
||||||
{"Logout"}
|
{"Logout"}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
pub mod add_group_member;
|
pub mod add_group_member;
|
||||||
pub mod add_user_to_group;
|
pub mod add_user_to_group;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod avatar;
|
||||||
|
pub mod banner;
|
||||||
pub mod change_password;
|
pub mod change_password;
|
||||||
pub mod create_group;
|
pub mod create_group;
|
||||||
|
pub mod create_group_attribute;
|
||||||
pub mod create_user;
|
pub mod create_user;
|
||||||
|
pub mod create_user_attribute;
|
||||||
pub mod delete_group;
|
pub mod delete_group;
|
||||||
|
pub mod delete_group_attribute;
|
||||||
pub mod delete_user;
|
pub mod delete_user;
|
||||||
|
pub mod delete_user_attribute;
|
||||||
|
pub mod form;
|
||||||
pub mod group_details;
|
pub mod group_details;
|
||||||
|
pub mod group_details_form;
|
||||||
|
pub mod group_schema_table;
|
||||||
pub mod group_table;
|
pub mod group_table;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
@@ -17,4 +26,5 @@ pub mod router;
|
|||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod user_details;
|
pub mod user_details;
|
||||||
pub mod user_details_form;
|
pub mod user_details_form;
|
||||||
|
pub mod user_schema_table;
|
||||||
pub mod user_table;
|
pub mod user_table;
|
||||||
|
|||||||
@@ -31,15 +31,18 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
Msg::SubmitRemoveGroup => self.submit_remove_group(ctx),
|
||||||
Msg::RemoveGroupResponse(response) => {
|
Msg::RemoveGroupResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.common.cancel_task();
|
ctx.props()
|
||||||
self.common
|
|
||||||
.on_user_removed_from_group
|
.on_user_removed_from_group
|
||||||
.emit((self.common.username.clone(), self.common.group_id));
|
.emit((ctx.props().username.clone(), ctx.props().group_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -51,11 +54,12 @@ impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupCompon
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RemoveUserFromGroupComponent {
|
impl RemoveUserFromGroupComponent {
|
||||||
fn submit_remove_group(&mut self) {
|
fn submit_remove_group(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
||||||
|
ctx,
|
||||||
remove_user_from_group::Variables {
|
remove_user_from_group::Variables {
|
||||||
user: self.common.username.clone(),
|
user: ctx.props().username.clone(),
|
||||||
group: self.common.group_id,
|
group: ctx.props().group_id,
|
||||||
},
|
},
|
||||||
Msg::RemoveGroupResponse,
|
Msg::RemoveGroupResponse,
|
||||||
"Error trying to initiate removing the user from a group",
|
"Error trying to initiate removing the user from a group",
|
||||||
@@ -67,30 +71,28 @@ impl Component for RemoveUserFromGroupComponent {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
onclick={link.callback(|_| Msg::SubmitRemoveGroup)}>
|
||||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::router::{AppRoute, Link},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -31,7 +31,11 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
@@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
|||||||
}
|
}
|
||||||
let FormModel { username } = self.form.model();
|
let FormModel { username } = self.form.model();
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::reset_password_step1,
|
ctx,
|
||||||
&username,
|
HostService::reset_password_step1(username),
|
||||||
Msg::PasswordResetResponse,
|
Msg::PasswordResetResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::PasswordResetResponse(response) => {
|
Msg::PasswordResetResponse(response) => {
|
||||||
@@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
ResetPasswordStep1Form {
|
ResetPasswordStep1Form {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: Form::<FormModel>::new(FormModel::default()),
|
form: Form::<FormModel>::new(FormModel::default()),
|
||||||
just_succeeded: false,
|
just_succeeded: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
self.just_succeeded = false;
|
self.just_succeeded = false;
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<FormModel>;
|
type Field = yew_form::Field<FormModel>;
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<form
|
<form
|
||||||
class="form center-block col-sm-4 col-offset-4">
|
class="form center-block col-sm-4 col-offset-4">
|
||||||
@@ -95,15 +96,19 @@ impl Component for ResetPasswordStep1Form {
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="username"
|
field_name="username"
|
||||||
placeholder="Username"
|
placeholder="Username or email"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</div>
|
</div>
|
||||||
{ if self.just_succeeded {
|
{ if self.just_succeeded {
|
||||||
html! {
|
html! {
|
||||||
{"A reset token has been sent to your email."}
|
{"If a user with this username or email exists, a password reset email will \
|
||||||
|
be sent to the associated email address. Please check your email and \
|
||||||
|
follow the instructions. If you don't receive an email, please check \
|
||||||
|
your spam folder. If you still don't receive an email, please contact \
|
||||||
|
your administrator."}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
@@ -111,23 +116,24 @@ impl Component for ResetPasswordStep1Form {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||||
|
<i class="bi-check-circle me-2"/>
|
||||||
{"Reset password"}
|
{"Reset password"}
|
||||||
</button>
|
</button>
|
||||||
<NavButton
|
<Link
|
||||||
classes="btn-link btn"
|
classes="btn-link btn"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
route=AppRoute::Login>
|
to={AppRoute::Login}>
|
||||||
{"Back"}
|
{"Back"}
|
||||||
</NavButton>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger mb-2">
|
||||||
{e.to_string() }
|
{e.to_string() }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::AppRoute,
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Result};
|
||||||
use lldap_auth::{
|
use lldap_auth::{
|
||||||
opaque::client::registration as opaque_registration,
|
opaque::client::registration as opaque_registration,
|
||||||
password_reset::ServerPasswordResetResponse, registration,
|
password_reset::ServerPasswordResetResponse, registration,
|
||||||
@@ -14,10 +17,7 @@ use validator_derive::Validate;
|
|||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The fields of the form, with the constraints.
|
/// The fields of the form, with the constraints.
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
@@ -33,7 +33,6 @@ pub struct ResetPasswordStep2Form {
|
|||||||
form: Form<FormModel>,
|
form: Form<FormModel>,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
opaque_data: Option<opaque_registration::ClientRegistration>,
|
opaque_data: Option<opaque_registration::ClientRegistration>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||||
@@ -50,11 +49,15 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use anyhow::Context;
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ValidateTokenResponse(response) => {
|
Msg::ValidateTokenResponse(response) => {
|
||||||
self.username = Some(response?.user_id);
|
self.username = Some(response?.user_id);
|
||||||
self.common.cancel_task();
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::FormUpdate => Ok(true),
|
Msg::FormUpdate => Ok(true),
|
||||||
@@ -65,18 +68,18 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
|||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
let new_password = self.form.model().password;
|
let new_password = self.form.model().password;
|
||||||
let registration_start_request =
|
let registration_start_request =
|
||||||
opaque_registration::start_registration(&new_password, &mut rng)
|
opaque_registration::start_registration(new_password.as_bytes(), &mut rng)
|
||||||
.context("Could not initiate password change")?;
|
.context("Could not initiate password change")?;
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: self.username.clone().unwrap(),
|
username: self.username.as_ref().unwrap().into(),
|
||||||
registration_start_request: registration_start_request.message,
|
registration_start_request: registration_start_request.message,
|
||||||
};
|
};
|
||||||
self.opaque_data = Some(registration_start_request.state);
|
self.opaque_data = Some(registration_start_request.state);
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_start,
|
ctx,
|
||||||
req,
|
HostService::register_start(req),
|
||||||
Msg::RegistrationStartResponse,
|
Msg::RegistrationStartResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::RegistrationStartResponse(res) => {
|
Msg::RegistrationStartResponse(res) => {
|
||||||
@@ -94,17 +97,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
|||||||
registration_upload: registration_finish.message,
|
registration_upload: registration_finish.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_finish,
|
ctx,
|
||||||
req,
|
HostService::register_finish(req),
|
||||||
Msg::RegistrationFinishResponse,
|
Msg::RegistrationFinishResponse,
|
||||||
)?;
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
if response.is_ok() {
|
if response.is_ok() {
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::Login);
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
|
|
||||||
}
|
}
|
||||||
response?;
|
response?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -121,35 +122,28 @@ impl Component for ResetPasswordStep2Form {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut component = ResetPasswordStep2Form {
|
let mut component = ResetPasswordStep2Form {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||||
opaque_data: None,
|
opaque_data: None,
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
username: None,
|
username: None,
|
||||||
};
|
};
|
||||||
let token = component.common.token.clone();
|
let token = ctx.props().token.clone();
|
||||||
component
|
component.common.call_backend(
|
||||||
.common
|
ctx,
|
||||||
.call_backend(
|
HostService::reset_password_step2(token),
|
||||||
HostService::reset_password_step2,
|
Msg::ValidateTokenResponse,
|
||||||
&token,
|
);
|
||||||
Msg::ValidateTokenResponse,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
component
|
component
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
match (&self.username, &self.common.error) {
|
match (&self.username, &self.common.error) {
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
return html! {
|
return html! {
|
||||||
@@ -158,68 +152,44 @@ impl Component for ResetPasswordStep2Form {
|
|||||||
}
|
}
|
||||||
(None, Some(e)) => {
|
(None, Some(e)) => {
|
||||||
return html! {
|
return html! {
|
||||||
<div class="alert alert-danger">
|
<>
|
||||||
{e.to_string() }
|
<div class="alert alert-danger">
|
||||||
</div>
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
classes="btn-link btn"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
to={AppRoute::Login}>
|
||||||
|
{"Back"}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
};
|
};
|
||||||
type Field = yew_form::Field<FormModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h2>{"Reset your password"}</h2>
|
<h2>{"Reset your password"}</h2>
|
||||||
<form
|
<form class="form">
|
||||||
class="form">
|
<Field<FormModel>
|
||||||
<div class="form-group row">
|
label="New password"
|
||||||
<label for="new_password"
|
required=true
|
||||||
class="form-label col-sm-2 col-form-label">
|
form={&self.form}
|
||||||
{"New password*:"}
|
field_name="password"
|
||||||
</label>
|
autocomplete="new-password"
|
||||||
<div class="col-sm-10">
|
input_type="password"
|
||||||
<Field
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
form=&self.form
|
<Field<FormModel>
|
||||||
field_name="password"
|
label="Confirm password"
|
||||||
class="form-control"
|
required=true
|
||||||
class_invalid="is-invalid has-error"
|
form={&self.form}
|
||||||
class_valid="has-success"
|
field_name="confirm_password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<div class="invalid-feedback">
|
<Submit
|
||||||
{&self.form.field_message("password")}
|
disabled={self.common.is_task_running()}
|
||||||
</div>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
|
||||||
</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>
|
</form>
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
use yew_router::{
|
use yew_router::Routable;
|
||||||
components::{RouterAnchor, RouterButton},
|
|
||||||
Switch,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Switch, Debug, Clone)]
|
#[derive(Routable, Debug, Clone, PartialEq)]
|
||||||
pub enum AppRoute {
|
pub enum AppRoute {
|
||||||
#[to = "/login"]
|
#[at("/login")]
|
||||||
Login,
|
Login,
|
||||||
#[to = "/reset-password/step1"]
|
#[at("/reset-password/step1")]
|
||||||
StartResetPassword,
|
StartResetPassword,
|
||||||
#[to = "/reset-password/step2/{token}"]
|
#[at("/reset-password/step2/:token")]
|
||||||
FinishResetPassword(String),
|
FinishResetPassword { token: String },
|
||||||
#[to = "/users/create"]
|
#[at("/users/create")]
|
||||||
CreateUser,
|
CreateUser,
|
||||||
#[to = "/users"]
|
#[at("/users")]
|
||||||
ListUsers,
|
ListUsers,
|
||||||
#[to = "/user/{user_id}/password"]
|
#[at("/user/:user_id/password")]
|
||||||
ChangePassword(String),
|
ChangePassword { user_id: String },
|
||||||
#[to = "/user/{user_id}"]
|
#[at("/user/:user_id")]
|
||||||
UserDetails(String),
|
UserDetails { user_id: String },
|
||||||
#[to = "/groups/create"]
|
#[at("/groups/create")]
|
||||||
CreateGroup,
|
CreateGroup,
|
||||||
#[to = "/groups"]
|
#[at("/groups")]
|
||||||
ListGroups,
|
ListGroups,
|
||||||
#[to = "/group/{group_id}"]
|
#[at("/group/:group_id")]
|
||||||
GroupDetails(i64),
|
GroupDetails { group_id: i64 },
|
||||||
#[to = "/"]
|
#[at("/user-attributes")]
|
||||||
|
ListUserSchema,
|
||||||
|
#[at("/user-attributes/create")]
|
||||||
|
CreateUserAttribute,
|
||||||
|
#[at("/group-attributes")]
|
||||||
|
ListGroupSchema,
|
||||||
|
#[at("/group-attributes/create")]
|
||||||
|
CreateGroupAttribute,
|
||||||
|
#[at("/")]
|
||||||
Index,
|
Index,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Link = RouterAnchor<AppRoute>;
|
pub type Link = yew_router::components::Link<AppRoute>;
|
||||||
|
pub type Redirect = yew_router::components::Redirect<AppRoute>;
|
||||||
pub type NavButton = RouterButton<AppRoute>;
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
use yew::{html::ChangeData, prelude::*};
|
use yew::prelude::*;
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
pub struct Select {
|
pub struct Select {
|
||||||
link: ComponentLink<Self>,
|
|
||||||
props: SelectProps,
|
|
||||||
node_ref: NodeRef,
|
node_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,100 +11,70 @@ pub struct SelectProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub enum SelectMsg {
|
pub enum SelectMsg {
|
||||||
OnSelectChange(ChangeData),
|
OnSelectChange,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Select {
|
impl Select {
|
||||||
fn get_nth_child_props(&self, nth: i32) -> Option<SelectOptionProps> {
|
fn get_nth_child_props(&self, ctx: &Context<Self>, nth: i32) -> Option<SelectOptionProps> {
|
||||||
if nth == -1 {
|
if nth == -1 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.props
|
ctx.props()
|
||||||
.children
|
.children
|
||||||
.iter()
|
.iter()
|
||||||
.nth(nth as usize)
|
.nth(nth as usize)
|
||||||
.map(|child| child.props)
|
.map(|child| (*child.props).clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_selection_update(&self) {
|
fn send_selection_update(&self, ctx: &Context<Self>) {
|
||||||
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
|
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
|
||||||
self.props
|
ctx.props()
|
||||||
.on_selection_change
|
.on_selection_change
|
||||||
.emit(self.get_nth_child_props(select_node.selected_index()))
|
.emit(self.get_nth_child_props(ctx, select_node.selected_index()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for Select {
|
impl Component for Select {
|
||||||
type Message = SelectMsg;
|
type Message = SelectMsg;
|
||||||
type Properties = SelectProps;
|
type Properties = SelectProps;
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
link,
|
|
||||||
props,
|
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered(&mut self, _first_render: bool) {
|
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
|
||||||
self.send_selection_update();
|
self.send_selection_update(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, _: Self::Message) -> bool {
|
||||||
let SelectMsg::OnSelectChange(data) = msg;
|
self.send_selection_update(ctx);
|
||||||
match data {
|
|
||||||
ChangeData::Select(_) => self.send_selection_update(),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.props.children.neq_assign(props.children)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<select
|
<select class="form-select"
|
||||||
ref=self.node_ref.clone()
|
ref={self.node_ref.clone()}
|
||||||
disabled=self.props.children.is_empty()
|
disabled={ctx.props().children.is_empty()}
|
||||||
onchange=self.link.callback(SelectMsg::OnSelectChange)>
|
onchange={ctx.link().callback(|_| SelectMsg::OnSelectChange)}>
|
||||||
{ self.props.children.clone() }
|
{ ctx.props().children.clone() }
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SelectOption {
|
|
||||||
props: SelectOptionProps,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
|
||||||
pub struct SelectOptionProps {
|
pub struct SelectOptionProps {
|
||||||
pub value: String,
|
pub value: String,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for SelectOption {
|
#[function_component(SelectOption)]
|
||||||
type Message = ();
|
pub fn select_option(props: &SelectOptionProps) -> Html {
|
||||||
type Properties = SelectOptionProps;
|
html! {
|
||||||
|
<option value={props.value.clone()}>
|
||||||
fn create(props: Self::Properties, _: ComponentLink<Self>) -> Self {
|
{&props.text}
|
||||||
Self { props }
|
</option>
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, _: Self::Message) -> ShouldRender {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
|
||||||
self.props.neq_assign(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
|
||||||
<option value=self.props.value.clone()>
|
|
||||||
{&self.props.text}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ use crate::{
|
|||||||
components::{
|
components::{
|
||||||
add_user_to_group::AddUserToGroupComponent,
|
add_user_to_group::AddUserToGroupComponent,
|
||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link, NavButton},
|
router::{AppRoute, Link},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
},
|
},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::GraphQlAttributeSchema,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
@@ -22,12 +26,34 @@ pub struct GetUserDetails;
|
|||||||
|
|
||||||
pub type User = get_user_details::GetUserDetailsUser;
|
pub type User = get_user_details::GetUserDetailsUser;
|
||||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||||
|
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||||
|
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
||||||
|
pub type AttributeType = get_user_details::AttributeType;
|
||||||
|
|
||||||
|
convert_attribute_type!(AttributeType);
|
||||||
|
|
||||||
|
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||||
|
fn from(attr: &AttributeSchema) -> Self {
|
||||||
|
Self {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
is_list: attr.is_list,
|
||||||
|
is_readonly: attr.is_readonly,
|
||||||
|
is_editable: attr.is_editable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UserDetails {
|
pub struct UserDetails {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
/// 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_and_schema: Option<(User, Vec<AttributeSchema>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserDetails {
|
||||||
|
fn mut_groups(&mut self) -> &mut Vec<Group> {
|
||||||
|
&mut self.user_and_schema.as_mut().unwrap().0.groups
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State machine describing the possible transitions of the component state.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -47,25 +73,23 @@ pub struct Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetails> for UserDetails {
|
impl CommonComponent<UserDetails> for UserDetails {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserDetailsResponse(response) => match response {
|
Msg::UserDetailsResponse(response) => match response {
|
||||||
Ok(user) => self.user = Some(user.user),
|
Ok(user) => {
|
||||||
|
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.user = None;
|
self.user_and_schema = None;
|
||||||
bail!("Error getting user details: {}", e);
|
bail!("Error getting user details: {}", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Msg::OnError(e) => return Err(e),
|
Msg::OnError(e) => return Err(e),
|
||||||
Msg::OnUserAddedToGroup(group) => {
|
Msg::OnUserAddedToGroup(group) => {
|
||||||
self.user.as_mut().unwrap().groups.push(group);
|
self.mut_groups().push(group);
|
||||||
}
|
}
|
||||||
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
||||||
self.user
|
self.mut_groups().retain(|g| g.id != group_id);
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.groups
|
|
||||||
.retain(|g| g.id != group_id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -77,10 +101,11 @@ impl CommonComponent<UserDetails> for UserDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserDetails {
|
impl UserDetails {
|
||||||
fn get_user_details(&mut self) {
|
fn get_user_details(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<GetUserDetails, _>(
|
self.common.call_graphql::<GetUserDetails, _>(
|
||||||
|
ctx,
|
||||||
get_user_details::Variables {
|
get_user_details::Variables {
|
||||||
id: self.common.username.clone(),
|
id: ctx.props().username.clone(),
|
||||||
},
|
},
|
||||||
Msg::UserDetailsResponse,
|
Msg::UserDetailsResponse,
|
||||||
"Error trying to fetch user details",
|
"Error trying to fetch user details",
|
||||||
@@ -99,24 +124,25 @@ impl UserDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_group_memberships(&self, u: &User) -> Html {
|
fn view_group_memberships(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
let make_group_row = |group: &Group| {
|
let make_group_row = |group: &Group| {
|
||||||
let display_name = group.display_name.clone();
|
let display_name = group.display_name.clone();
|
||||||
html! {
|
html! {
|
||||||
<tr key="groupRow_".to_string() + &display_name>
|
<tr key={"groupRow_".to_string() + &display_name}>
|
||||||
{if self.common.is_admin { html! {
|
{if ctx.props().is_admin { html! {
|
||||||
<>
|
<>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::GroupDetails(group.id)>
|
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||||
{&group.display_name}
|
{&group.display_name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<RemoveUserFromGroupComponent
|
<RemoveUserFromGroupComponent
|
||||||
username=u.id.clone()
|
username={u.id.clone()}
|
||||||
group_id=group.id
|
group_id={group.id}
|
||||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
} } else { html! {
|
} } else { html! {
|
||||||
@@ -129,18 +155,18 @@ impl UserDetails {
|
|||||||
<>
|
<>
|
||||||
<h5 class="row m-3 fw-bold">{"Group memberships"}</h5>
|
<h5 class="row m-3 fw-bold">{"Group memberships"}</h5>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr key="headerRow">
|
<tr key="headerRow">
|
||||||
<th>{"Group"}</th>
|
<th>{"Group"}</th>
|
||||||
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
{ if ctx.props().is_admin { html!{ <th></th> }} else { html!{} }}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{if u.groups.is_empty() {
|
{if u.groups.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
<tr key="EmptyRow">
|
<tr key="EmptyRow">
|
||||||
<td>{"Not member of any group"}</td>
|
<td>{"This user is not a member of any groups."}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -153,14 +179,15 @@ impl UserDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_add_group_button(&self, u: &User) -> Html {
|
fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||||
if self.common.is_admin {
|
let link = &ctx.link();
|
||||||
|
if ctx.props().is_admin {
|
||||||
html! {
|
html! {
|
||||||
<AddUserToGroupComponent
|
<AddUserToGroupComponent
|
||||||
username=u.id.clone()
|
username={u.id.clone()}
|
||||||
groups=u.groups.clone()
|
groups={u.groups.clone()}
|
||||||
on_error=self.common.callback(Msg::OnError)
|
on_error={link.callback(Msg::OnError)}
|
||||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
@@ -172,46 +199,50 @@ impl Component for UserDetails {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
user: None,
|
user_and_schema: None,
|
||||||
};
|
};
|
||||||
table.get_user_details();
|
table.get_user_details(ctx);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
match (&self.user_and_schema, &self.common.error) {
|
||||||
}
|
(Some((u, schema)), error) => {
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
match (&self.user, &self.common.error) {
|
|
||||||
(None, None) => html! {{"Loading..."}},
|
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
|
||||||
(Some(u), error) => {
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{u.id.to_string()}</h3>
|
<h3>{u.id.to_string()}</h3>
|
||||||
<UserDetailsForm
|
<div class="d-flex flex-row-reverse">
|
||||||
user=u.clone() />
|
<Link
|
||||||
<div class="row justify-content-center">
|
to={AppRoute::ChangePassword{user_id: u.id.clone()}}
|
||||||
<NavButton
|
classes="btn btn-secondary">
|
||||||
route=AppRoute::ChangePassword(u.id.clone())
|
<i class="bi-key me-2"></i>
|
||||||
classes="btn btn-primary col-auto">
|
{"Modify password"}
|
||||||
{"Change password"}
|
</Link>
|
||||||
</NavButton>
|
|
||||||
</div>
|
</div>
|
||||||
{self.view_group_memberships(u)}
|
<div>
|
||||||
{self.view_add_group_button(u)}
|
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||||
|
</div>
|
||||||
|
<UserDetailsForm
|
||||||
|
user={u.clone()}
|
||||||
|
user_attributes_schema={schema.clone()}
|
||||||
|
is_admin={ctx.props().is_admin}
|
||||||
|
is_edited_user_admin={u.groups.iter().any(|g| g.display_name == "lldap_admin")}
|
||||||
|
/>
|
||||||
|
{self.view_group_memberships(ctx, u)}
|
||||||
|
{self.view_add_group_button(ctx, u)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(None, None) => html! {{"Loading..."}},
|
||||||
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,21 @@
|
|||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::user_details::User,
|
components::{
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
form::{
|
||||||
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
|
static_value::StaticValue,
|
||||||
|
submit::Submit,
|
||||||
|
},
|
||||||
|
user_details::{Attribute, AttributeSchema, User},
|
||||||
|
},
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{Ok, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use yew::prelude::*;
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use yew::{prelude::*, services::ConsoleService};
|
|
||||||
use yew_form_derive::Model;
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Clone, Default)]
|
|
||||||
struct JsFile {
|
|
||||||
file: Option<web_sys::File>,
|
|
||||||
contents: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToString for JsFile {
|
|
||||||
fn to_string(&self) -> String {
|
|
||||||
self.file
|
|
||||||
.as_ref()
|
|
||||||
.map(web_sys::File::name)
|
|
||||||
.unwrap_or_else(String::new)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for JsFile {
|
|
||||||
type Err = Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self> {
|
|
||||||
if s.is_empty() {
|
|
||||||
Ok(JsFile::default())
|
|
||||||
} else {
|
|
||||||
bail!("Building file from non-empty string")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fields of the form, with the editable details and the constraints.
|
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
|
||||||
pub struct UserModel {
|
|
||||||
#[validate(email)]
|
|
||||||
email: String,
|
|
||||||
#[validate(length(min = 1, message = "Display name is required"))]
|
|
||||||
display_name: String,
|
|
||||||
first_name: String,
|
|
||||||
last_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The GraphQL query sent to the server to update the user details.
|
/// The GraphQL query sent to the server to update the user details.
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
@@ -63,10 +31,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 {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<UserModel>,
|
|
||||||
avatar: JsFile,
|
|
||||||
/// True if we just successfully updated the user, to display a success message.
|
/// True if we just successfully updated the user, to display a success message.
|
||||||
just_updated: bool,
|
just_updated: bool,
|
||||||
|
user: User,
|
||||||
|
form_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
@@ -74,8 +42,6 @@ pub enum Msg {
|
|||||||
Update,
|
Update,
|
||||||
/// The "Submit" button was clicked.
|
/// The "Submit" button was clicked.
|
||||||
SubmitClicked,
|
SubmitClicked,
|
||||||
/// A picked file finished loading.
|
|
||||||
FileLoaded(yew::services::reader::FileData),
|
|
||||||
/// We got the response from the server about our update message.
|
/// We got the response from the server about our update message.
|
||||||
UserUpdated(Result<update_user::ResponseData>),
|
UserUpdated(Result<update_user::ResponseData>),
|
||||||
}
|
}
|
||||||
@@ -84,58 +50,25 @@ pub enum Msg {
|
|||||||
pub struct Props {
|
pub struct Props {
|
||||||
/// The current user details.
|
/// The current user details.
|
||||||
pub user: User,
|
pub user: User,
|
||||||
|
pub user_attributes_schema: Vec<AttributeSchema>,
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub is_edited_user_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => {
|
Msg::Update => Ok(true),
|
||||||
let window = web_sys::window().expect("no global `window` exists");
|
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||||
let document = window.document().expect("should have a document on window");
|
Msg::UserUpdated(Err(e)) => Err(e),
|
||||||
let input = document
|
Msg::UserUpdated(Result::Ok(_)) => {
|
||||||
.get_element_by_id("avatarInput")
|
self.just_updated = true;
|
||||||
.expect("Form field avatarInput should be present")
|
|
||||||
.dyn_into::<web_sys::HtmlInputElement>()
|
|
||||||
.expect("Should be an HtmlInputElement");
|
|
||||||
ConsoleService::log("Form update");
|
|
||||||
if let Some(files) = input.files() {
|
|
||||||
ConsoleService::log("Got file list");
|
|
||||||
if files.length() > 0 {
|
|
||||||
ConsoleService::log("Got a file");
|
|
||||||
let new_avatar = JsFile {
|
|
||||||
file: files.item(0),
|
|
||||||
contents: None,
|
|
||||||
};
|
|
||||||
if self.avatar.file.as_ref().map(|f| f.name())
|
|
||||||
!= new_avatar.file.as_ref().map(|f| f.name())
|
|
||||||
{
|
|
||||||
if let Some(ref file) = new_avatar.file {
|
|
||||||
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
|
|
||||||
}
|
|
||||||
self.avatar = new_avatar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
|
||||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
|
||||||
Msg::FileLoaded(data) => {
|
|
||||||
self.common.cancel_task();
|
|
||||||
if let Some(file) = &self.avatar.file {
|
|
||||||
if file.name() == data.name {
|
|
||||||
if !is_valid_jpeg(data.content.as_slice()) {
|
|
||||||
// Clear the selection.
|
|
||||||
self.avatar = JsFile::default();
|
|
||||||
bail!("Chosen image is not a valid JPEG");
|
|
||||||
} else {
|
|
||||||
self.avatar.contents = Some(data.content);
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,165 +81,65 @@ impl Component for UserDetailsForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let model = UserModel {
|
|
||||||
email: props.user.email.clone(),
|
|
||||||
display_name: props.user.display_name.clone(),
|
|
||||||
first_name: props.user.first_name.clone(),
|
|
||||||
last_name: props.user.last_name.clone(),
|
|
||||||
};
|
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::new(model),
|
|
||||||
avatar: JsFile::default(),
|
|
||||||
just_updated: false,
|
just_updated: false,
|
||||||
|
user: ctx.props().user.clone(),
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
self.just_updated = false;
|
self.just_updated = false;
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
let can_edit =
|
||||||
type Field = yew_form::Field<UserModel>;
|
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||||
|
let display_field = |a: &AttributeSchema| {
|
||||||
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
|
if can_edit(a) {
|
||||||
let avatar_string = avatar_base64.as_ref().unwrap_or(&self.common.user.avatar);
|
get_custom_attribute_input(a, &self.user.attributes)
|
||||||
|
} else {
|
||||||
|
get_custom_attribute_static(a, &self.user.attributes)
|
||||||
|
}
|
||||||
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<form class="form">
|
<form
|
||||||
<div class="form-group row mb-3">
|
class="form"
|
||||||
<label for="userId"
|
ref={self.form_ref.clone()}>
|
||||||
class="form-label col-4 col-form-label">
|
<StaticValue label="User ID" id="userId">
|
||||||
{"User ID: "}
|
<i>{&self.user.id}</i>
|
||||||
</label>
|
</StaticValue>
|
||||||
<div class="col-8">
|
{
|
||||||
<span id="userId" class="form-constrol-static"><b>{&self.common.user.id}</b></span>
|
ctx
|
||||||
</div>
|
.props()
|
||||||
</div>
|
.user_attributes_schema
|
||||||
<div class="form-group row mb-3">
|
.iter()
|
||||||
<div class="col-4 col-form-label">
|
.filter(|a| a.is_hardcoded && a.name != "user_id")
|
||||||
<img
|
.map(display_field)
|
||||||
id="avatarDisplay"
|
.collect::<Vec<_>>()
|
||||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
}
|
||||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
{
|
||||||
alt="Avatar" />
|
ctx
|
||||||
</div>
|
.props()
|
||||||
<div class="col-8">
|
.user_attributes_schema
|
||||||
<input
|
.iter()
|
||||||
class="form-control"
|
.filter(|a| !a.is_hardcoded)
|
||||||
id="avatarInput"
|
.map(display_field)
|
||||||
type="file"
|
.collect::<Vec<_>>()
|
||||||
accept="image/jpeg"
|
}
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
<Submit
|
||||||
</div>
|
text="Save changes"
|
||||||
</div>
|
disabled={self.common.is_task_running()}
|
||||||
<div class="form-group row mb-3">
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||||
<label for="email"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Email*: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
form=&self.form
|
|
||||||
field_name="email"
|
|
||||||
autocomplete="email"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("email")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="display_name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Display Name*: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
form=&self.form
|
|
||||||
field_name="display_name"
|
|
||||||
autocomplete="name"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("display_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="first_name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"First Name: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
class="form-control"
|
|
||||||
form=&self.form
|
|
||||||
field_name="first_name"
|
|
||||||
autocomplete="given-name"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("first_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="last_name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Last Name: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
class="form-control"
|
|
||||||
form=&self.form
|
|
||||||
field_name="last_name"
|
|
||||||
autocomplete="family-name"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("last_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="creationDate"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Creation date: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<span id="creationDate" class="form-constrol-static">{&self.common.user.creation_date.date().naive_local()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="uuid"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"UUID: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<span id="creationDate" class="form-constrol-static">{&self.common.user.uuid}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row justify-content-center">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary col-auto col-form-label"
|
|
||||||
disabled=self.common.is_task_running()
|
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
|
||||||
{"Update"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.common.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() }
|
||||||
@@ -314,113 +147,129 @@ impl Component for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
} else { html! {} }
|
} else { html! {} }
|
||||||
}
|
}
|
||||||
<div hidden=!self.just_updated>
|
<div hidden={!self.just_updated}>
|
||||||
<span>{"User successfully updated!"}</span>
|
<div class="alert alert-success mt-4">{"User successfully updated!"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(
|
||||||
|
attribute_schema: &AttributeSchema,
|
||||||
|
user_attributes: &[Attribute],
|
||||||
|
) -> Html {
|
||||||
|
let values = user_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == attribute_schema.name)
|
||||||
|
.map(|attribute| attribute.value.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if attribute_schema.is_list {
|
||||||
|
html! {
|
||||||
|
<ListAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<SingleAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
value={values.first().cloned().unwrap_or_default()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_static(
|
||||||
|
attribute_schema: &AttributeSchema,
|
||||||
|
user_attributes: &[Attribute],
|
||||||
|
) -> Html {
|
||||||
|
let values = user_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == attribute_schema.name)
|
||||||
|
.map(|attribute| attribute.value.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
html! {
|
||||||
|
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||||
|
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||||
|
</StaticValue>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl UserDetailsForm {
|
impl UserDetailsForm {
|
||||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
ConsoleService::log("Submit");
|
// TODO: Handle unloaded files.
|
||||||
if !self.form.validate() {
|
// if let Some(JsFile {
|
||||||
bail!("Invalid inputs");
|
// file: Some(_),
|
||||||
}
|
// contents: None,
|
||||||
ConsoleService::log("Valid inputs");
|
// }) = &self.avatar
|
||||||
if let JsFile {
|
// {
|
||||||
file: Some(_),
|
// bail!("Image file hasn't finished loading, try again");
|
||||||
contents: None,
|
// }
|
||||||
} = &self.avatar
|
let mut all_values = read_all_form_attributes(
|
||||||
{
|
ctx.props().user_attributes_schema.iter(),
|
||||||
bail!("Image file hasn't finished loading, try again");
|
&self.form_ref,
|
||||||
}
|
IsAdmin(ctx.props().is_admin),
|
||||||
ConsoleService::log("File is correctly loaded");
|
EmailIsRequired(!ctx.props().is_edited_user_admin),
|
||||||
let base_user = &self.common.user;
|
)?;
|
||||||
|
let base_attributes = &self.user.attributes;
|
||||||
|
all_values.retain(|a| {
|
||||||
|
let base_val = base_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|base_val| base_val.name == a.name);
|
||||||
|
base_val
|
||||||
|
.map(|v| v.value != a.values)
|
||||||
|
.unwrap_or(!a.values.is_empty())
|
||||||
|
});
|
||||||
|
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||||
|
};
|
||||||
|
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
|
||||||
|
if remove_attributes.is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| !a.values.is_empty())
|
||||||
|
.map(
|
||||||
|
|AttributeValue { name, values }| update_user::AttributeValueInput {
|
||||||
|
name,
|
||||||
|
value: values,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
let mut user_input = update_user::UpdateUserInput {
|
let mut user_input = update_user::UpdateUserInput {
|
||||||
id: self.common.user.id.clone(),
|
id: self.user.id.clone(),
|
||||||
email: None,
|
email: None,
|
||||||
displayName: None,
|
displayName: None,
|
||||||
firstName: None,
|
firstName: None,
|
||||||
lastName: None,
|
lastName: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
|
removeAttributes: None,
|
||||||
|
insertAttributes: None,
|
||||||
};
|
};
|
||||||
let default_user_input = user_input.clone();
|
let default_user_input = user_input.clone();
|
||||||
let model = self.form.model();
|
user_input.removeAttributes = remove_attributes;
|
||||||
let email = model.email;
|
user_input.insertAttributes = insert_attributes;
|
||||||
if base_user.email != email {
|
|
||||||
user_input.email = Some(email);
|
|
||||||
}
|
|
||||||
if base_user.display_name != model.display_name {
|
|
||||||
user_input.displayName = Some(model.display_name);
|
|
||||||
}
|
|
||||||
if base_user.first_name != model.first_name {
|
|
||||||
user_input.firstName = Some(model.first_name);
|
|
||||||
}
|
|
||||||
if base_user.last_name != model.last_name {
|
|
||||||
user_input.lastName = Some(model.last_name);
|
|
||||||
}
|
|
||||||
user_input.avatar = maybe_to_base64(&self.avatar)?;
|
|
||||||
// Nothing changed.
|
// Nothing changed.
|
||||||
if user_input == default_user_input {
|
if user_input == default_user_input {
|
||||||
ConsoleService::log("No changes");
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let req = update_user::Variables { user: user_input };
|
let req = update_user::Variables { user: user_input };
|
||||||
ConsoleService::log("Querying");
|
|
||||||
self.common.call_graphql::<UpdateUser, _>(
|
self.common.call_graphql::<UpdateUser, _>(
|
||||||
|
ctx,
|
||||||
req,
|
req,
|
||||||
Msg::UserUpdated,
|
Msg::UserUpdated,
|
||||||
"Error trying to update user",
|
"Error trying to update user",
|
||||||
);
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
|
||||||
self.common.cancel_task();
|
|
||||||
match r {
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
Ok(_) => {
|
|
||||||
let model = self.form.model();
|
|
||||||
self.common.user.email = model.email;
|
|
||||||
self.common.user.display_name = model.display_name;
|
|
||||||
self.common.user.first_name = model.first_name;
|
|
||||||
self.common.user.last_name = model.last_name;
|
|
||||||
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
|
|
||||||
self.common.user.avatar = avatar;
|
|
||||||
}
|
|
||||||
self.just_updated = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
|
||||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
|
||||||
.decode()
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_to_base64(file: &JsFile) -> Result<Option<String>> {
|
|
||||||
match file {
|
|
||||||
JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: _,
|
|
||||||
} => Ok(None),
|
|
||||||
JsFile {
|
|
||||||
file: Some(_),
|
|
||||||
contents: None,
|
|
||||||
} => bail!("Image file hasn't finished loading, try again"),
|
|
||||||
JsFile {
|
|
||||||
file: Some(_),
|
|
||||||
contents: Some(data),
|
|
||||||
} => {
|
|
||||||
if !is_valid_jpeg(data.as_slice()) {
|
|
||||||
bail!("Chosen image is not a valid JPEG");
|
|
||||||
}
|
|
||||||
Ok(Some(base64::encode(data)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
198
app/src/components/user_schema_table.rs
Normal file
198
app/src/components/user_schema_table.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
delete_user_attribute::DeleteUserAttribute,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, Error, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_user_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetUserAttributesSchema;
|
||||||
|
|
||||||
|
use get_user_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub hardcoded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserSchemaTable {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
attributes: Option<Vec<Attribute>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
|
OnAttributeDeleted(String),
|
||||||
|
OnError(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<UserSchemaTable> for UserSchemaTable {
|
||||||
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes = Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::OnError(e) => Err(e),
|
||||||
|
Msg::OnAttributeDeleted(attribute_name) => {
|
||||||
|
match self.attributes {
|
||||||
|
None => {
|
||||||
|
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||||
|
Err(anyhow!("invalid state"))
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
self.attributes
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|a| a.name != attribute_name);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for UserSchemaTable {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let mut table = UserSchemaTable {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
attributes: None,
|
||||||
|
};
|
||||||
|
table.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_user_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch user schema",
|
||||||
|
);
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
{self.view_attributes(ctx)}
|
||||||
|
{self.view_errors()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserSchemaTable {
|
||||||
|
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
let make_table = |attributes: &Vec<Attribute>| {
|
||||||
|
html! {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{"Attribute name"}</th>
|
||||||
|
<th>{"Type"}</th>
|
||||||
|
<th>{"Editable"}</th>
|
||||||
|
<th>{"Visible"}</th>
|
||||||
|
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match &self.attributes {
|
||||||
|
None => html! {{"Loading..."}},
|
||||||
|
Some(attributes) => {
|
||||||
|
let mut attributes = attributes.clone();
|
||||||
|
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||||
|
make_table(&attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||||
|
let checkmark = html! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||||
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||||
|
</svg>
|
||||||
|
};
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
html! {
|
||||||
|
<tr key={attribute.name.clone()}>
|
||||||
|
<td>{&attribute.name}</td>
|
||||||
|
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||||
|
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
|
||||||
|
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||||
|
{
|
||||||
|
if hardcoded {
|
||||||
|
html!{}
|
||||||
|
} else {
|
||||||
|
html!{
|
||||||
|
<td>
|
||||||
|
<DeleteUserAttribute
|
||||||
|
attribute_name={attribute.name.clone()}
|
||||||
|
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
|
||||||
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_errors(&self) -> Html {
|
||||||
|
match &self.common.error {
|
||||||
|
None => html! {},
|
||||||
|
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ListUserSchema)]
|
||||||
|
pub fn list_user_schema() -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<UserSchemaTable hardcoded={true} />
|
||||||
|
<UserSchemaTable hardcoded={false} />
|
||||||
|
<Link classes="btn btn-primary" to={AppRoute::CreateUserAttribute}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Create an attribute"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserTable> for UserTable {
|
impl CommonComponent<UserTable> for UserTable {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ListUsersResponse(users) => {
|
Msg::ListUsersResponse(users) => {
|
||||||
self.users = Some(users?.users.into_iter().collect());
|
self.users = Some(users?.users.into_iter().collect());
|
||||||
@@ -55,8 +55,9 @@ impl CommonComponent<UserTable> for UserTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserTable {
|
impl UserTable {
|
||||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
fn get_users(&mut self, ctx: &Context<Self>, req: Option<RequestFilter>) {
|
||||||
self.common.call_graphql::<ListUsersQuery, _>(
|
self.common.call_graphql::<ListUsersQuery, _>(
|
||||||
|
ctx,
|
||||||
list_users_query::Variables { filters: req },
|
list_users_query::Variables { filters: req },
|
||||||
Msg::ListUsersResponse,
|
Msg::ListUsersResponse,
|
||||||
"Error trying to fetch users",
|
"Error trying to fetch users",
|
||||||
@@ -68,27 +69,23 @@ impl Component for UserTable {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = UserTable {
|
let mut table = UserTable {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
users: None,
|
users: None,
|
||||||
};
|
};
|
||||||
table.get_users(None);
|
table.get_users(ctx, None);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_users()}
|
{self.view_users(ctx)}
|
||||||
{self.view_errors()}
|
{self.view_errors()}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -96,11 +93,11 @@ impl Component for UserTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserTable {
|
impl UserTable {
|
||||||
fn view_users(&self) -> Html {
|
fn view_users(&self, ctx: &Context<Self>) -> Html {
|
||||||
let make_table = |users: &Vec<User>| {
|
let make_table = |users: &Vec<User>| {
|
||||||
html! {
|
html! {
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{"User ID"}</th>
|
<th>{"User ID"}</th>
|
||||||
@@ -113,7 +110,7 @@ impl UserTable {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.iter().map(|u| self.view_user(u)).collect::<Vec<_>>()}
|
{users.iter().map(|u| self.view_user(ctx, u)).collect::<Vec<_>>()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,20 +122,21 @@ impl UserTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_user(&self, user: &User) -> Html {
|
fn view_user(&self, ctx: &Context<Self>, user: &User) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<tr key=user.id.clone()>
|
<tr key={user.id.clone()}>
|
||||||
<td><Link route=AppRoute::UserDetails(user.id.clone())>{&user.id}</Link></td>
|
<td><Link to={AppRoute::UserDetails{user_id: user.id.clone()}}>{&user.id}</Link></td>
|
||||||
<td>{&user.email}</td>
|
<td>{&user.email}</td>
|
||||||
<td>{&user.display_name}</td>
|
<td>{&user.display_name}</td>
|
||||||
<td>{&user.first_name}</td>
|
<td>{&user.first_name}</td>
|
||||||
<td>{&user.last_name}</td>
|
<td>{&user.last_name}</td>
|
||||||
<td>{&user.creation_date.date().naive_local()}</td>
|
<td>{&user.creation_date.naive_local().date()}</td>
|
||||||
<td>
|
<td>
|
||||||
<DeleteUser
|
<DeleteUser
|
||||||
username=user.id.clone()
|
username={user.id.clone()}
|
||||||
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
on_user_deleted={link.callback(Msg::OnUserDeleted)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,136 +1,95 @@
|
|||||||
use super::cookies::set_cookie;
|
use super::cookies::set_cookie;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use gloo_net::http::{Method, RequestBuilder};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{login, registration, JWTClaims};
|
use lldap_auth::{login, registration, JWTClaims};
|
||||||
|
|
||||||
use yew::callback::Callback;
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use yew::format::Json;
|
use web_sys::RequestCredentials;
|
||||||
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct HostService {}
|
pub struct HostService {}
|
||||||
|
|
||||||
fn get_default_options() -> FetchOptions {
|
|
||||||
FetchOptions {
|
|
||||||
credentials: Some(Credentials::SameOrigin),
|
|
||||||
..FetchOptions::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||||
use jwt::*;
|
use jwt::*;
|
||||||
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
||||||
Ok(token.claims().clone())
|
Ok(token.claims().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_handler<Resp, CallbackResult, F>(
|
enum RequestType<Body: Serialize> {
|
||||||
callback: Callback<Result<CallbackResult>>,
|
Get,
|
||||||
handler: F,
|
Post(Body),
|
||||||
) -> Callback<Response<Result<Resp>>>
|
|
||||||
where
|
|
||||||
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
|
|
||||||
CallbackResult: 'static,
|
|
||||||
{
|
|
||||||
Callback::once(move |response: Response<Result<Resp>>| {
|
|
||||||
let (meta, maybe_data) = response.into_parts();
|
|
||||||
let message = maybe_data
|
|
||||||
.context("Could not reach server")
|
|
||||||
.and_then(|data| handler(meta.status, data));
|
|
||||||
callback.emit(message)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RequestBody<T>(T);
|
const GET_REQUEST: RequestType<()> = RequestType::Get;
|
||||||
|
|
||||||
impl<'a, R> From<&'a R> for RequestBody<Json<&'a R>>
|
fn base_url() -> String {
|
||||||
where
|
yew_router::utils::base_url().unwrap_or_default()
|
||||||
R: serde::ser::Serialize,
|
}
|
||||||
{
|
|
||||||
fn from(request: &'a R) -> Self {
|
async fn call_server<Body: Serialize>(
|
||||||
Self(Json(request))
|
url: &str,
|
||||||
|
body: RequestType<Body>,
|
||||||
|
error_message: &'static str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let request_builder = RequestBuilder::new(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.credentials(RequestCredentials::SameOrigin);
|
||||||
|
let request = if let RequestType::Post(b) = body {
|
||||||
|
request_builder
|
||||||
|
.method(Method::POST)
|
||||||
|
.body(serde_json::to_string(&b)?)?
|
||||||
|
} else {
|
||||||
|
request_builder.build()?
|
||||||
|
};
|
||||||
|
let response = request.send().await?;
|
||||||
|
if response.ok() {
|
||||||
|
Ok(response.text().await?)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!(
|
||||||
|
"{}[{} {}]: {}",
|
||||||
|
error_message,
|
||||||
|
response.status(),
|
||||||
|
response.status_text(),
|
||||||
|
response.text().await?
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<yew::format::Nothing> for RequestBody<yew::format::Nothing> {
|
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||||
fn from(request: yew::format::Nothing) -> Self {
|
url: &str,
|
||||||
Self(request)
|
request: RequestType<Body>,
|
||||||
}
|
error_message: &'static str,
|
||||||
|
) -> Result<CallbackResult>
|
||||||
|
where
|
||||||
|
CallbackResult: DeserializeOwned + 'static,
|
||||||
|
{
|
||||||
|
let data = call_server(url, request, error_message).await?;
|
||||||
|
serde_json::from_str(&data).context("Could not parse response")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call_server<Req, CallbackResult, F, RB>(
|
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||||
url: &str,
|
url: &str,
|
||||||
request: RB,
|
request: RequestType<Body>,
|
||||||
callback: Callback<Result<CallbackResult>>,
|
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
parse_response: F,
|
) -> Result<()> {
|
||||||
) -> Result<FetchTask>
|
call_server(url, request, error_message).await.map(|_| ())
|
||||||
where
|
|
||||||
F: Fn(String) -> Result<CallbackResult> + 'static,
|
|
||||||
CallbackResult: 'static,
|
|
||||||
RB: Into<RequestBody<Req>>,
|
|
||||||
Req: Into<yew::format::Text>,
|
|
||||||
{
|
|
||||||
let request = {
|
|
||||||
// If the request type is empty (if the size is 0), it's a get.
|
|
||||||
if std::mem::size_of::<RB>() == 0 {
|
|
||||||
Request::get(url)
|
|
||||||
} else {
|
|
||||||
Request::post(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(request.into().0)?;
|
|
||||||
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
|
|
||||||
if status.is_success() {
|
|
||||||
parse_response(data)
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("{}[{}]: {}", error_message, status, data))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
FetchService::fetch_with_options(request, get_default_options(), handler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call_server_json_with_error_message<CallbackResult, RB, Req>(
|
fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, bool)> {
|
||||||
url: &str,
|
let jwt_claims = get_claims_from_jwt(response.token.as_str()).context("Could not parse JWT")?;
|
||||||
request: RB,
|
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||||
callback: Callback<Result<CallbackResult>>,
|
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||||
error_message: &'static str,
|
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||||
) -> Result<FetchTask>
|
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||||
where
|
.context("Error setting cookie")
|
||||||
CallbackResult: serde::de::DeserializeOwned + 'static,
|
|
||||||
RB: Into<RequestBody<Req>>,
|
|
||||||
Req: Into<yew::format::Text>,
|
|
||||||
{
|
|
||||||
call_server(url, request, callback, error_message, |data: String| {
|
|
||||||
serde_json::from_str(&data).context("Could not parse response")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_server_empty_response_with_error_message<RB, Req>(
|
|
||||||
url: &str,
|
|
||||||
request: RB,
|
|
||||||
callback: Callback<Result<()>>,
|
|
||||||
error_message: &'static str,
|
|
||||||
) -> Result<FetchTask>
|
|
||||||
where
|
|
||||||
RB: Into<RequestBody<Req>>,
|
|
||||||
Req: Into<yew::format::Text>,
|
|
||||||
{
|
|
||||||
call_server(
|
|
||||||
url,
|
|
||||||
request,
|
|
||||||
callback,
|
|
||||||
error_message,
|
|
||||||
|_data: String| Ok(()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostService {
|
impl HostService {
|
||||||
pub fn graphql_query<QueryType>(
|
pub async fn graphql_query<QueryType>(
|
||||||
variables: QueryType::Variables,
|
variables: QueryType::Variables,
|
||||||
callback: Callback<Result<QueryType::ResponseData>>,
|
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<FetchTask>
|
) -> Result<QueryType::ResponseData>
|
||||||
where
|
where
|
||||||
QueryType: GraphQLQuery + 'static,
|
QueryType: GraphQLQuery + 'static,
|
||||||
{
|
{
|
||||||
@@ -147,143 +106,111 @@ impl HostService {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let parse_graphql_response = move |data: String| {
|
|
||||||
serde_json::from_str(&data)
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(unwrap_graphql_response)
|
|
||||||
};
|
|
||||||
let request_body = QueryType::build_query(variables);
|
let request_body = QueryType::build_query(variables);
|
||||||
call_server(
|
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||||
"/api/graphql",
|
&(base_url() + "/api/graphql"),
|
||||||
&request_body,
|
RequestType::Post(request_body),
|
||||||
callback,
|
|
||||||
error_message,
|
error_message,
|
||||||
parse_graphql_response,
|
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
.and_then(unwrap_graphql_response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login_start(
|
pub async fn login_start(
|
||||||
request: login::ClientLoginStartRequest,
|
request: login::ClientLoginStartRequest,
|
||||||
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>,
|
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
"/auth/opaque/login/start",
|
&(base_url() + "/auth/opaque/login/start"),
|
||||||
&request,
|
RequestType::Post(request),
|
||||||
callback,
|
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login_finish(
|
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||||
request: login::ClientLoginFinishRequest,
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
callback: Callback<Result<(String, bool)>>,
|
&(base_url() + "/auth/opaque/login/finish"),
|
||||||
) -> Result<FetchTask> {
|
RequestType::Post(request),
|
||||||
let set_cookies = |jwt_claims: JWTClaims| {
|
|
||||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
|
||||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
|
||||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
|
||||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
|
||||||
.context("Error clearing cookie")
|
|
||||||
};
|
|
||||||
let parse_token = move |data: String| {
|
|
||||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(|r| {
|
|
||||||
get_claims_from_jwt(r.token.as_str())
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(set_cookies)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
call_server(
|
|
||||||
"/auth/opaque/login/finish",
|
|
||||||
&request,
|
|
||||||
callback,
|
|
||||||
"Could not finish authentication",
|
"Could not finish authentication",
|
||||||
parse_token,
|
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
.and_then(set_cookies_from_jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_start(
|
pub async fn register_start(
|
||||||
request: registration::ClientRegistrationStartRequest,
|
request: registration::ClientRegistrationStartRequest,
|
||||||
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
|
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
"/auth/opaque/register/start",
|
&(base_url() + "/auth/opaque/register/start"),
|
||||||
&request,
|
RequestType::Post(request),
|
||||||
callback,
|
|
||||||
"Could not start registration: ",
|
"Could not start registration: ",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_finish(
|
pub async fn register_finish(
|
||||||
request: registration::ClientRegistrationFinishRequest,
|
request: registration::ClientRegistrationFinishRequest,
|
||||||
callback: Callback<Result<()>>,
|
) -> Result<()> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
"/auth/opaque/register/finish",
|
&(base_url() + "/auth/opaque/register/finish"),
|
||||||
&request,
|
RequestType::Post(request),
|
||||||
callback,
|
|
||||||
"Could not finish registration",
|
"Could not finish registration",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
|
pub async fn refresh() -> Result<(String, bool)> {
|
||||||
let set_cookies = |jwt_claims: JWTClaims| {
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
&(base_url() + "/auth/refresh"),
|
||||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
GET_REQUEST,
|
||||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
|
||||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
|
||||||
.context("Error clearing cookie")
|
|
||||||
};
|
|
||||||
let parse_token = move |data: String| {
|
|
||||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(|r| {
|
|
||||||
get_claims_from_jwt(r.token.as_str())
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(set_cookies)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
call_server(
|
|
||||||
"/auth/refresh",
|
|
||||||
yew::format::Nothing,
|
|
||||||
callback,
|
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
parse_token,
|
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
.and_then(set_cookies_from_jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The `_request` parameter is to make it the same shape as the other functions.
|
// The `_request` parameter is to make it the same shape as the other functions.
|
||||||
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
|
pub async fn logout() -> Result<()> {
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
"/auth/logout",
|
&(base_url() + "/auth/logout"),
|
||||||
yew::format::Nothing,
|
GET_REQUEST,
|
||||||
callback,
|
|
||||||
"Could not logout",
|
"Could not logout",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_password_step1(
|
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||||
username: &str,
|
|
||||||
callback: Callback<Result<()>>,
|
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
&format!("/auth/reset/step1/{}", username),
|
&format!(
|
||||||
yew::format::Nothing,
|
"{}/auth/reset/step1/{}",
|
||||||
callback,
|
base_url(),
|
||||||
|
url_escape::encode_query(&username)
|
||||||
|
),
|
||||||
|
RequestType::Post(""),
|
||||||
"Could not initiate password reset",
|
"Could not initiate password reset",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_password_step2(
|
pub async fn reset_password_step2(
|
||||||
token: &str,
|
token: String,
|
||||||
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
|
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&format!("/auth/reset/step2/{}", token),
|
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
||||||
yew::format::Nothing,
|
GET_REQUEST,
|
||||||
callback,
|
|
||||||
"Could not validate token",
|
"Could not validate token",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn probe_password_reset() -> Result<bool> {
|
||||||
|
Ok(gloo_net::http::Request::post(
|
||||||
|
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
|
||||||
|
)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status()
|
||||||
|
!= http::StatusCode::NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,88 +21,62 @@
|
|||||||
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
||||||
//! take care of error and task handling.
|
//! take care of error and task handling.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
future::Future,
|
||||||
|
marker::PhantomData,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::infra::api::HostService;
|
use crate::infra::api::HostService;
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
|
use gloo_console::error;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::{
|
use yew::prelude::*;
|
||||||
prelude::*,
|
|
||||||
services::{
|
|
||||||
fetch::FetchTask,
|
|
||||||
reader::{FileData, ReaderService, ReaderTask},
|
|
||||||
ConsoleService,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
/// Trait required for common components.
|
/// Trait required for common components.
|
||||||
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||||
/// Handle the incoming message. If an error is returned here, any running task will be
|
/// Handle the incoming message. If an error is returned here, any running task will be
|
||||||
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
||||||
/// component will be refreshed.
|
/// component will be refreshed.
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool>;
|
||||||
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
||||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AnyTask {
|
|
||||||
None,
|
|
||||||
FetchTask(FetchTask),
|
|
||||||
ReaderTask(ReaderTask),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AnyTask {
|
|
||||||
fn is_some(&self) -> bool {
|
|
||||||
!matches!(self, AnyTask::None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Option<FetchTask>> for AnyTask {
|
|
||||||
fn from(task: Option<FetchTask>) -> Self {
|
|
||||||
match task {
|
|
||||||
Some(t) => AnyTask::FetchTask(t),
|
|
||||||
None => AnyTask::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Structure that contains the common parts needed by most components.
|
/// Structure that contains the common parts needed by most components.
|
||||||
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
||||||
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
||||||
link: ComponentLink<C>,
|
|
||||||
pub props: <C as Component>::Properties,
|
|
||||||
pub error: Option<Error>,
|
pub error: Option<Error>,
|
||||||
task: AnyTask,
|
is_task_running: Arc<Mutex<bool>>,
|
||||||
|
_phantom: PhantomData<C>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||||
|
pub fn create() -> Self {
|
||||||
|
CommonComponentParts {
|
||||||
|
error: None,
|
||||||
|
is_task_running: Arc::new(Mutex::new(false)),
|
||||||
|
_phantom: PhantomData::<C>,
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Whether there is a currently running task in the background.
|
/// Whether there is a currently running task in the background.
|
||||||
pub fn is_task_running(&self) -> bool {
|
pub fn is_task_running(&self) -> bool {
|
||||||
self.task.is_some()
|
*self.is_task_running.lock().unwrap()
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel any background task.
|
|
||||||
pub fn cancel_task(&mut self) {
|
|
||||||
self.task = AnyTask::None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
|
||||||
Self {
|
|
||||||
link,
|
|
||||||
props,
|
|
||||||
error: None,
|
|
||||||
task: AnyTask::None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
||||||
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
||||||
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
|
pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
|
||||||
com.mut_common().error = None;
|
com.mut_common().error = None;
|
||||||
match com.handle_msg(msg) {
|
match com.handle_msg(ctx, msg) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
ConsoleService::error(&e.to_string());
|
error!(&e.to_string());
|
||||||
com.mut_common().error = Some(e);
|
com.mut_common().error = Some(e);
|
||||||
com.mut_common().cancel_task();
|
assert!(!*com.mut_common().is_task_running.lock().unwrap());
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
@@ -112,10 +86,11 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
/// Same as above, but the resulting error is instead passed to the reporting function.
|
/// Same as above, but the resulting error is instead passed to the reporting function.
|
||||||
pub fn update_and_report_error(
|
pub fn update_and_report_error(
|
||||||
com: &mut C,
|
com: &mut C,
|
||||||
|
ctx: &Context<C>,
|
||||||
msg: <C as Component>::Message,
|
msg: <C as Component>::Message,
|
||||||
report_fn: Callback<Error>,
|
report_fn: Callback<Error>,
|
||||||
) -> ShouldRender {
|
) -> bool {
|
||||||
let should_render = Self::update(com, msg);
|
let should_render = Self::update(com, ctx, msg);
|
||||||
com.mut_common()
|
com.mut_common()
|
||||||
.error
|
.error
|
||||||
.take()
|
.take()
|
||||||
@@ -126,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
.unwrap_or(should_render)
|
.unwrap_or(should_render)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
|
|
||||||
/// properties have changed and return whether the component should update.
|
|
||||||
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
|
|
||||||
where
|
|
||||||
<C as yew::Component>::Properties: std::cmp::PartialEq,
|
|
||||||
{
|
|
||||||
self.props.neq_assign(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a callback from the link.
|
|
||||||
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
|
|
||||||
where
|
|
||||||
M: Into<C::Message>,
|
|
||||||
F: Fn(IN) -> M + 'static,
|
|
||||||
{
|
|
||||||
self.link.callback(function)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
||||||
/// result. Returns whether _starting the call_ failed.
|
/// result.
|
||||||
pub fn call_backend<M, Req, Cb, Resp>(
|
pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
|
||||||
&mut self,
|
|
||||||
method: M,
|
|
||||||
req: Req,
|
|
||||||
callback: Cb,
|
|
||||||
) -> Result<()>
|
|
||||||
where
|
where
|
||||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
Fut: Future<Output = Resp> + 'static,
|
||||||
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
||||||
{
|
{
|
||||||
self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?);
|
{
|
||||||
Ok(())
|
let mut running = self.is_task_running.lock().unwrap();
|
||||||
|
assert!(!*running);
|
||||||
|
*running = true;
|
||||||
|
}
|
||||||
|
let is_task_running = self.is_task_running.clone();
|
||||||
|
ctx.link().send_future(async move {
|
||||||
|
let res = fut.await;
|
||||||
|
*is_task_running.lock().unwrap() = false;
|
||||||
|
callback(res)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call the backend with a GraphQL query.
|
/// Call the backend with a GraphQL query.
|
||||||
@@ -165,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
/// `EnumCallback` should usually be left as `_`.
|
/// `EnumCallback` should usually be left as `_`.
|
||||||
pub fn call_graphql<QueryType, EnumCallback>(
|
pub fn call_graphql<QueryType, EnumCallback>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
ctx: &Context<C>,
|
||||||
variables: QueryType::Variables,
|
variables: QueryType::Variables,
|
||||||
enum_callback: EnumCallback,
|
enum_callback: EnumCallback,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
@@ -172,41 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
QueryType: GraphQLQuery + 'static,
|
QueryType: GraphQLQuery + 'static,
|
||||||
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
||||||
{
|
{
|
||||||
self.task = HostService::graphql_query::<QueryType>(
|
self.call_backend(
|
||||||
variables,
|
ctx,
|
||||||
self.link.callback(enum_callback),
|
HostService::graphql_query::<QueryType>(variables, error_message),
|
||||||
error_message,
|
enum_callback,
|
||||||
)
|
);
|
||||||
.map_err::<(), _>(|e| {
|
|
||||||
ConsoleService::log(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
.into();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
|
|
||||||
where
|
|
||||||
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
|
|
||||||
{
|
|
||||||
self.task = AnyTask::ReaderTask(ReaderService::read_file(
|
|
||||||
file,
|
|
||||||
self.link.callback_once(callback),
|
|
||||||
)?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
|
|
||||||
type Target = <C as Component>::Properties;
|
|
||||||
|
|
||||||
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
|
|
||||||
&self.props
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
|
|
||||||
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
|
|
||||||
&mut self.props
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) ->
|
|||||||
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
|
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
|
||||||
})?;
|
})?;
|
||||||
let cookie_string = format!(
|
let cookie_string = format!(
|
||||||
"{}={}; expires={}; sameSite=Strict; path=/",
|
"{}={}; expires={}; sameSite=Strict; path={}/",
|
||||||
cookie_name,
|
cookie_name,
|
||||||
value,
|
value,
|
||||||
expiration.to_rfc2822()
|
expiration.to_rfc2822(),
|
||||||
|
yew_router::utils::base_url().unwrap_or_default()
|
||||||
);
|
);
|
||||||
doc.set_cookie(&cookie_string)
|
doc.set_cookie(&cookie_string)
|
||||||
.map_err(|_| anyhow!("Could not set cookie"))
|
.map_err(|_| anyhow!("Could not set cookie"))
|
||||||
@@ -53,7 +54,11 @@ pub fn get_cookie(cookie_name: &str) -> Result<Option<String>> {
|
|||||||
|
|
||||||
pub fn delete_cookie(cookie_name: &str) -> Result<()> {
|
pub fn delete_cookie(cookie_name: &str) -> Result<()> {
|
||||||
if get_cookie(cookie_name)?.is_some() {
|
if get_cookie(cookie_name)?.is_some() {
|
||||||
set_cookie(cookie_name, "", &Utc.ymd(1970, 1, 1).and_hms(0, 0, 0))
|
set_cookie(
|
||||||
|
cookie_name,
|
||||||
|
"",
|
||||||
|
&Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
70
app/src/infra/form_utils.rs
Normal file
70
app/src/infra/form_utils.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use anyhow::{anyhow, ensure, Result};
|
||||||
|
use validator::validate_email;
|
||||||
|
use web_sys::{FormData, HtmlFormElement};
|
||||||
|
use yew::NodeRef;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AttributeValue {
|
||||||
|
pub name: String,
|
||||||
|
pub values: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GraphQlAttributeSchema {
|
||||||
|
pub name: String,
|
||||||
|
pub is_list: bool,
|
||||||
|
pub is_readonly: bool,
|
||||||
|
pub is_editable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_attributes(
|
||||||
|
all_values: &[AttributeValue],
|
||||||
|
email_is_required: EmailIsRequired,
|
||||||
|
) -> Result<()> {
|
||||||
|
let maybe_email_values = all_values.iter().find(|a| a.name == "mail");
|
||||||
|
if email_is_required.0 || maybe_email_values.is_some() {
|
||||||
|
let email_values = &maybe_email_values
|
||||||
|
.ok_or_else(|| anyhow!("Email is required"))?
|
||||||
|
.values;
|
||||||
|
ensure!(email_values.len() == 1, "Email is required");
|
||||||
|
ensure!(validate_email(&email_values[0]), "Email is not valid");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IsAdmin(pub bool);
|
||||||
|
pub struct EmailIsRequired(pub bool);
|
||||||
|
|
||||||
|
pub fn read_all_form_attributes(
|
||||||
|
schema: impl IntoIterator<Item = impl Into<GraphQlAttributeSchema>>,
|
||||||
|
form_ref: &NodeRef,
|
||||||
|
is_admin: IsAdmin,
|
||||||
|
email_is_required: EmailIsRequired,
|
||||||
|
) -> Result<Vec<AttributeValue>> {
|
||||||
|
let form = form_ref.cast::<HtmlFormElement>().unwrap();
|
||||||
|
let form_data = FormData::new_with_form(&form)
|
||||||
|
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
||||||
|
let all_values = schema
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::<GraphQlAttributeSchema>::into)
|
||||||
|
.filter(|attr| !attr.is_readonly && (is_admin.0 || attr.is_editable))
|
||||||
|
.map(|attr| -> Result<AttributeValue> {
|
||||||
|
let val = form_data
|
||||||
|
.get_all(attr.name.as_str())
|
||||||
|
.iter()
|
||||||
|
.map(|js_val| js_val.as_string().unwrap_or_default())
|
||||||
|
.filter(|val| !val.is_empty())
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
ensure!(
|
||||||
|
val.len() <= 1 || attr.is_list,
|
||||||
|
"Multiple values supplied for non-list attribute {}",
|
||||||
|
attr.name
|
||||||
|
);
|
||||||
|
Ok(AttributeValue {
|
||||||
|
name: attr.name.clone(),
|
||||||
|
values: val,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
validate_attributes(&all_values, email_is_required)?;
|
||||||
|
Ok(all_values)
|
||||||
|
}
|
||||||
59
app/src/infra/functional.rs
Normal file
59
app/src/infra/functional.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::infra::api::HostService;
|
||||||
|
use anyhow::Result;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
use yew::{use_effect_with_deps, use_state_eq, UseStateHandle};
|
||||||
|
|
||||||
|
// Enum to represent a result that is fetched asynchronously.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum LoadableResult<T> {
|
||||||
|
// The result is still being fetched
|
||||||
|
Loading,
|
||||||
|
// The async call is completed
|
||||||
|
Loaded(Result<T>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: PartialEq> PartialEq for LoadableResult<T> {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
match (self, other) {
|
||||||
|
(LoadableResult::Loading, LoadableResult::Loading) => true,
|
||||||
|
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
|
||||||
|
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
|
||||||
|
e1.to_string().eq(&e2.to_string())
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_graphql_call<QueryType>(
|
||||||
|
variables: QueryType::Variables,
|
||||||
|
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
||||||
|
where
|
||||||
|
QueryType: GraphQLQuery + 'static,
|
||||||
|
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
|
||||||
|
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
||||||
|
{
|
||||||
|
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||||
|
use_state_eq(|| LoadableResult::Loading);
|
||||||
|
{
|
||||||
|
let loadable_result = loadable_result.clone();
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |variables| {
|
||||||
|
let task = HostService::graphql_query::<QueryType>(
|
||||||
|
variables.clone(),
|
||||||
|
"Failed graphql query",
|
||||||
|
);
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = task.await;
|
||||||
|
loadable_result.set(LoadableResult::Loaded(response));
|
||||||
|
});
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
},
|
||||||
|
variables,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadable_result.clone()
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod common_component;
|
pub mod common_component;
|
||||||
pub mod cookies;
|
pub mod cookies;
|
||||||
|
pub mod form_utils;
|
||||||
|
pub mod functional;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
pub mod schema;
|
||||||
|
pub mod tooltip;
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
|
#![allow(clippy::empty_docs)]
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
#[wasm_bindgen(module = "bootstrap")]
|
#[wasm_bindgen]
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||||
pub type Modal;
|
pub type Modal;
|
||||||
|
|
||||||
#[wasm_bindgen(constructor)]
|
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||||
pub fn new(e: web_sys::Element) -> Modal;
|
pub fn new(e: web_sys::Element) -> Modal;
|
||||||
|
|
||||||
#[wasm_bindgen(method)]
|
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||||
pub fn show(this: &Modal);
|
pub fn show(this: &Modal);
|
||||||
|
|
||||||
#[wasm_bindgen(method)]
|
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||||
pub fn hide(this: &Modal);
|
pub fn hide(this: &Modal);
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/src/infra/schema.rs
Normal file
66
app/src/infra/schema.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use std::{fmt::Display, str::FromStr};
|
||||||
|
use validator::ValidationError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AttributeType {
|
||||||
|
String,
|
||||||
|
Integer,
|
||||||
|
DateTime,
|
||||||
|
Jpeg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for AttributeType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{:?}", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for AttributeType {
|
||||||
|
type Err = ();
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"String" => Ok(AttributeType::String),
|
||||||
|
"Integer" => Ok(AttributeType::Integer),
|
||||||
|
"DateTime" => Ok(AttributeType::DateTime),
|
||||||
|
"Jpeg" => Ok(AttributeType::Jpeg),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Macro to generate traits for converting between AttributeType and the
|
||||||
|
// graphql generated equivalents.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! convert_attribute_type {
|
||||||
|
($source_type:ty) => {
|
||||||
|
impl From<$source_type> for $crate::infra::schema::AttributeType {
|
||||||
|
fn from(value: $source_type) -> Self {
|
||||||
|
match value {
|
||||||
|
<$source_type>::STRING => $crate::infra::schema::AttributeType::String,
|
||||||
|
<$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
|
||||||
|
<$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
|
||||||
|
<$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
|
||||||
|
_ => panic!("Unknown attribute type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<$crate::infra::schema::AttributeType> for $source_type {
|
||||||
|
fn from(value: $crate::infra::schema::AttributeType) -> Self {
|
||||||
|
match value {
|
||||||
|
$crate::infra::schema::AttributeType::String => <$source_type>::STRING,
|
||||||
|
$crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
|
||||||
|
$crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||||
|
$crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
||||||
|
AttributeType::from_str(attribute_type)
|
||||||
|
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
12
app/src/infra/tooltip.rs
Normal file
12
app/src/infra/tooltip.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#![allow(clippy::empty_docs)]
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||||
|
pub type Tooltip;
|
||||||
|
|
||||||
|
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||||
|
pub fn new(e: web_sys::Element) -> Tooltip;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
#![forbid(non_ascii_idents)]
|
#![forbid(non_ascii_idents)]
|
||||||
#![allow(clippy::nonstandard_macro_braces)]
|
#![allow(clippy::uninlined_format_args)]
|
||||||
|
#![allow(clippy::let_unit_value)]
|
||||||
|
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
|
|
||||||
@@ -8,7 +10,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
|||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn run_app() -> Result<(), JsValue> {
|
pub fn run_app() -> Result<(), JsValue> {
|
||||||
yew::start_app::<components::app::App>();
|
yew::start_app::<components::app::AppContainer>();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css
|
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css
|
||||||
|
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js
|
||||||
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
|
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
|
||||||
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
|
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
|
||||||
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
|
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
|
||||||
10
app/static/main.js
Normal file
10
app/static/main.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import init, { run_app } from '/pkg/lldap_app.js';
|
||||||
|
async function main() {
|
||||||
|
if(navigator.userAgent.indexOf('AppleWebKit') != -1) {
|
||||||
|
await init('/pkg/lldap_app_bg.wasm');
|
||||||
|
} else {
|
||||||
|
await init('/pkg/lldap_app_bg.wasm.gz');
|
||||||
|
}
|
||||||
|
run_app();
|
||||||
|
}
|
||||||
|
main()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
header h1 {
|
header h2 {
|
||||||
font-family: 'Bebas Neue', cursive;
|
font-family: 'Bebas Neue', cursive;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10,3 +10,23 @@ header h1 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark .bg-light {
|
||||||
|
background-color: rgba(59,59,59,1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark a {
|
||||||
|
color: #e1e1e1
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #212529
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .nav-link {
|
||||||
|
color: #e1e1e1
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #212529
|
||||||
|
}
|
||||||
@@ -1,36 +1,52 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lldap_auth"
|
|
||||||
version = "0.3.0-alpha.1"
|
|
||||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||||
|
description = "Authentication protocol for LLDAP"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
homepage = "https://github.com/lldap/lldap"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
name = "lldap_auth"
|
||||||
|
repository = "https://github.com/lldap/lldap"
|
||||||
|
version = "0.6.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["opaque_server", "opaque_client"]
|
default = ["opaque_server", "opaque_client"]
|
||||||
opaque_server = []
|
opaque_server = []
|
||||||
opaque_client = []
|
opaque_client = []
|
||||||
js = []
|
js = []
|
||||||
|
sea_orm = ["dep:sea-orm"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust-argon2 = "0.8"
|
rust-argon2 = "0.8"
|
||||||
curve25519-dalek = "3"
|
curve25519-dalek = "3"
|
||||||
digest = "0.9"
|
digest = "0.9"
|
||||||
generic-array = "*"
|
generic-array = "0.14"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = "*"
|
serde = "*"
|
||||||
sha2 = "0.9"
|
sha2 = "0.9"
|
||||||
thiserror = "*"
|
thiserror = "*"
|
||||||
|
|
||||||
|
[dependencies.derive_more]
|
||||||
|
features = ["debug", "display"]
|
||||||
|
default-features = false
|
||||||
|
version = "1"
|
||||||
|
|
||||||
[dependencies.opaque-ke]
|
[dependencies.opaque-ke]
|
||||||
version = "0.6"
|
version = "0.7"
|
||||||
|
|
||||||
[dependencies.chrono]
|
[dependencies.chrono]
|
||||||
version = "*"
|
version = "*"
|
||||||
features = [ "serde" ]
|
features = ["serde"]
|
||||||
|
|
||||||
|
[dependencies.sea-orm]
|
||||||
|
version = "0.12"
|
||||||
|
default-features = false
|
||||||
|
features = ["macros"]
|
||||||
|
optional = true
|
||||||
|
|
||||||
# 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"
|
||||||
features = ["js"]
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
||||||
version = "0.2"
|
version = "0.2"
|
||||||
|
features = ["js"]
|
||||||
|
|||||||
117
auth/src/lib.rs
117
auth/src/lib.rs
@@ -9,17 +9,17 @@ pub mod opaque;
|
|||||||
|
|
||||||
/// The messages for the 3-step OPAQUE and simple login process.
|
/// The messages for the 3-step OPAQUE and simple login process.
|
||||||
pub mod login {
|
pub mod login {
|
||||||
use super::*;
|
use super::{types::UserId, *};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ServerData {
|
pub struct ServerData {
|
||||||
pub username: String,
|
pub username: UserId,
|
||||||
pub server_login: opaque::server::login::ServerLogin,
|
pub server_login: opaque::server::login::ServerLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ClientLoginStartRequest {
|
pub struct ClientLoginStartRequest {
|
||||||
pub username: String,
|
pub username: UserId,
|
||||||
pub login_start_request: opaque::server::login::CredentialRequest,
|
pub login_start_request: opaque::server::login::CredentialRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +39,14 @@ pub mod login {
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ClientSimpleLoginRequest {
|
pub struct ClientSimpleLoginRequest {
|
||||||
pub username: String,
|
pub username: UserId,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for ClientSimpleLoginRequest {
|
impl fmt::Debug for ClientSimpleLoginRequest {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("ClientSimpleLoginRequest")
|
f.debug_struct("ClientSimpleLoginRequest")
|
||||||
.field("username", &self.username)
|
.field("username", &self.username.as_str())
|
||||||
.field("password", &"***********")
|
.field("password", &"***********")
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -63,16 +63,16 @@ pub mod login {
|
|||||||
/// The messages for the 3-step OPAQUE registration process.
|
/// The messages for the 3-step OPAQUE registration process.
|
||||||
/// It is used to reset a user's password.
|
/// It is used to reset a user's password.
|
||||||
pub mod registration {
|
pub mod registration {
|
||||||
use super::*;
|
use super::{types::UserId, *};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ServerData {
|
pub struct ServerData {
|
||||||
pub username: String,
|
pub username: UserId,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ClientRegistrationStartRequest {
|
pub struct ClientRegistrationStartRequest {
|
||||||
pub username: String,
|
pub username: UserId,
|
||||||
pub registration_start_request: opaque::server::registration::RegistrationRequest,
|
pub registration_start_request: opaque::server::registration::RegistrationRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +104,107 @@ pub mod password_reset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod types {
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[cfg(feature = "sea_orm")]
|
||||||
|
use sea_orm::{DbErr, DeriveValueType, TryFromU64, Value};
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||||
|
#[serde(from = "String")]
|
||||||
|
pub struct CaseInsensitiveString(String);
|
||||||
|
|
||||||
|
impl CaseInsensitiveString {
|
||||||
|
pub fn new(s: &str) -> Self {
|
||||||
|
Self(s.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
self.0.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_string(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for CaseInsensitiveString {
|
||||||
|
fn from(mut s: String) -> Self {
|
||||||
|
s.make_ascii_lowercase();
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&String> for CaseInsensitiveString {
|
||||||
|
fn from(s: &String) -> Self {
|
||||||
|
Self::new(s.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for CaseInsensitiveString {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
Self::new(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
PartialEq,
|
||||||
|
Eq,
|
||||||
|
PartialOrd,
|
||||||
|
Ord,
|
||||||
|
Clone,
|
||||||
|
Default,
|
||||||
|
Hash,
|
||||||
|
Serialize,
|
||||||
|
Deserialize,
|
||||||
|
derive_more::Debug,
|
||||||
|
derive_more::Display,
|
||||||
|
)]
|
||||||
|
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||||
|
#[serde(from = "CaseInsensitiveString")]
|
||||||
|
#[debug(r#""{}""#, _0.as_str())]
|
||||||
|
#[display("{}", _0.as_str())]
|
||||||
|
pub struct UserId(CaseInsensitiveString);
|
||||||
|
|
||||||
|
impl UserId {
|
||||||
|
pub fn new(s: &str) -> Self {
|
||||||
|
s.into()
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
self.0.as_str()
|
||||||
|
}
|
||||||
|
pub fn into_string(self) -> String {
|
||||||
|
self.0.into_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<T> From<T> for UserId
|
||||||
|
where
|
||||||
|
T: Into<CaseInsensitiveString>,
|
||||||
|
{
|
||||||
|
fn from(s: T) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "sea_orm")]
|
||||||
|
impl From<&UserId> for Value {
|
||||||
|
fn from(user_id: &UserId) -> Self {
|
||||||
|
user_id.as_str().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(feature = "sea_orm")]
|
||||||
|
impl TryFromU64 for UserId {
|
||||||
|
fn try_from_u64(_n: u64) -> Result<Self, DbErr> {
|
||||||
|
Err(DbErr::ConvertFromU64(
|
||||||
|
"UserId cannot be constructed from u64",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct JWTClaims {
|
pub struct JWTClaims {
|
||||||
pub exp: DateTime<Utc>,
|
pub exp: DateTime<Utc>,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::types::UserId;
|
||||||
use opaque_ke::ciphersuite::CipherSuite;
|
use opaque_ke::ciphersuite::CipherSuite;
|
||||||
use rand::{CryptoRng, RngCore};
|
use rand::{CryptoRng, RngCore};
|
||||||
|
|
||||||
@@ -77,10 +78,10 @@ pub mod client {
|
|||||||
pub use opaque_ke::ClientRegistrationFinishParameters;
|
pub use opaque_ke::ClientRegistrationFinishParameters;
|
||||||
/// Initiate the registration negotiation.
|
/// Initiate the registration negotiation.
|
||||||
pub fn start_registration<R: RngCore + CryptoRng>(
|
pub fn start_registration<R: RngCore + CryptoRng>(
|
||||||
password: &str,
|
password: &[u8],
|
||||||
rng: &mut R,
|
rng: &mut R,
|
||||||
) -> AuthenticationResult<ClientRegistrationStartResult> {
|
) -> AuthenticationResult<ClientRegistrationStartResult> {
|
||||||
Ok(ClientRegistration::start(rng, password.as_bytes())?)
|
Ok(ClientRegistration::start(rng, password)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finalize the registration negotiation.
|
/// Finalize the registration negotiation.
|
||||||
@@ -145,12 +146,12 @@ pub mod server {
|
|||||||
pub fn start_registration(
|
pub fn start_registration(
|
||||||
server_setup: &ServerSetup,
|
server_setup: &ServerSetup,
|
||||||
registration_request: RegistrationRequest,
|
registration_request: RegistrationRequest,
|
||||||
username: &str,
|
username: &UserId,
|
||||||
) -> AuthenticationResult<ServerRegistrationStartResult> {
|
) -> AuthenticationResult<ServerRegistrationStartResult> {
|
||||||
Ok(ServerRegistration::start(
|
Ok(ServerRegistration::start(
|
||||||
server_setup,
|
server_setup,
|
||||||
registration_request,
|
registration_request,
|
||||||
username.as_bytes(),
|
username.as_str().as_bytes(),
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,14 +179,14 @@ pub mod server {
|
|||||||
server_setup: &ServerSetup,
|
server_setup: &ServerSetup,
|
||||||
password_file: Option<ServerRegistration>,
|
password_file: Option<ServerRegistration>,
|
||||||
credential_request: CredentialRequest,
|
credential_request: CredentialRequest,
|
||||||
username: &str,
|
username: &UserId,
|
||||||
) -> AuthenticationResult<ServerLoginStartResult> {
|
) -> AuthenticationResult<ServerLoginStartResult> {
|
||||||
Ok(ServerLogin::start(
|
Ok(ServerLogin::start(
|
||||||
rng,
|
rng,
|
||||||
server_setup,
|
server_setup,
|
||||||
password_file,
|
password_file,
|
||||||
credential_request,
|
credential_request,
|
||||||
username.as_bytes(),
|
username.as_str().as_bytes(),
|
||||||
ServerLoginStartParameters::default(),
|
ServerLoginStartParameters::default(),
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|||||||
20
docker-entrypoint-rootless.sh
Executable file
20
docker-entrypoint-rootless.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG_FILE=/data/lldap_config.toml
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
echo "[entrypoint] Copying the default config to $CONFIG_FILE"
|
||||||
|
echo "[entrypoint] Edit this $CONFIG_FILE to configure LLDAP."
|
||||||
|
if cp /app/lldap_config.docker_template.toml $CONFIG_FILE; then
|
||||||
|
echo "Configuration copied successfully."
|
||||||
|
else
|
||||||
|
echo "Fail to copy configuration, check permission on /data or manually create one by copying from LLDAP repository"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "> Starting lldap.."
|
||||||
|
echo ""
|
||||||
|
exec /app/lldap "$@"
|
||||||
|
exec "$@"
|
||||||
@@ -14,21 +14,20 @@ Backend:
|
|||||||
is defined in `schema.graphql`.
|
is defined in `schema.graphql`.
|
||||||
* The static frontend files are served by this port too.
|
* The static frontend files are served by this port too.
|
||||||
|
|
||||||
Note that secure protocols (LDAPS, HTTPS) are currently not supported. This can
|
Note that HTTPS is currently not supported. This can be worked around by using
|
||||||
be worked around by using a reverse proxy in front of the server (for the HTTP
|
a reverse proxy in front of the server (for the HTTP API) that wraps/unwraps
|
||||||
API) that wraps/unwraps the HTTPS messages, or only open the service to
|
the HTTPS messages. LDAPS is supported.
|
||||||
localhost or other trusted docker containers (for the LDAP API).
|
|
||||||
|
|
||||||
Frontend:
|
Frontend:
|
||||||
* User management UI.
|
* User management UI.
|
||||||
* Written in Rust compiled to WASM as an SPA with the Yew library.
|
* Written in Rust compiled to WASM as an SPA with the Yew library.
|
||||||
* Based on components, with a React-like organization.
|
* Based on components, with a React-like framework.
|
||||||
|
|
||||||
Data storage:
|
Data storage:
|
||||||
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
|
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
|
||||||
* Currently only SQLite is supported (see
|
* The main SQL DBs are supported: SQLite by default, MySQL, MariaDB, PostgreSQL
|
||||||
https://github.com/launchbadge/sqlx/issues/1225 for what blocks us from
|
(see [DB Migration](/database_migration.md) for how to migrate off of
|
||||||
supporting more SQL backends).
|
SQLite).
|
||||||
|
|
||||||
### Code organization
|
### Code organization
|
||||||
|
|
||||||
@@ -50,19 +49,19 @@ Data storage:
|
|||||||
Authentication is done via the OPAQUE protocol, meaning that the passwords are
|
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
|
never sent to the server, but instead the client proves that they know the
|
||||||
correct password (zero-knowledge proof). This is likely overkill, especially
|
correct password (zero-knowledge proof). This is likely overkill, especially
|
||||||
considered that the LDAP interface requires sending the password to the server,
|
considered that the LDAP interface requires sending the password in cleartext
|
||||||
but it's one less potential flaw (especially since the LDAP interface can be
|
to the server, but it's one less potential flaw (especially since the LDAP
|
||||||
restricted to an internal docker-only network while the web app is exposed to
|
interface can be restricted to an internal docker-only network while the web
|
||||||
the Internet).
|
app is exposed to the Internet).
|
||||||
|
|
||||||
OPAQUE's "passwords" (user-specific blobs of data that can only be used in a
|
OPAQUE's "passwords" (user-specific blobs of data that can only be used in a
|
||||||
zero-knowledge proof that the password is correct) are hashed using Argon2, the
|
zero-knowledge proof that the password is correct) are hashed using Argon2, the
|
||||||
state of the art in terms of password storage. They are hashed using a secret
|
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
|
provided in the configuration (which can be given as environment variable,
|
||||||
command line argument as well): this should be kept secret and shouldn't change
|
command line argument or a file as well): this should be kept secret and
|
||||||
(it would invalidate all passwords). Note that even if it was compromised, the
|
shouldn't change (it would invalidate all passwords). Note that even if it was
|
||||||
attacker wouldn't be able to decrypt the passwords without running an expensive
|
compromised, the attacker wouldn't be able to decrypt the passwords without
|
||||||
brute-force search independently for each password.
|
running an expensive brute-force search independently for each password.
|
||||||
|
|
||||||
### JWTs and refresh tokens
|
### JWTs and refresh tokens
|
||||||
|
|
||||||
|
|||||||
BIN
docs/cookie.png
Normal file
BIN
docs/cookie.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user