Compare commits
493 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
32f28d664e | ||
|
|
412f4fa644 | ||
|
|
4ffa565e51 | ||
|
|
2f9ea4f10f | ||
|
|
123fdc5baf | ||
|
|
5402aa5aa2 | ||
|
|
8069516283 | ||
|
|
6c21f2ef4b | ||
|
|
516893f1f7 | ||
|
|
1660cb1fbb | ||
|
|
7e1ce10df1 | ||
|
|
b6ee918ca9 | ||
|
|
24efd61464 | ||
|
|
0b6b274cfa | ||
|
|
8b01271e94 | ||
|
|
d536addf0a | ||
|
|
2ca083541e | ||
|
|
686bdc0cb1 | ||
|
|
60c594438c | ||
|
|
b130965264 | ||
|
|
697a64991d | ||
|
|
3acc448048 | ||
|
|
0e3c5120da | ||
|
|
7707367c35 | ||
|
|
122e08790f | ||
|
|
64556fc744 | ||
|
|
134a9366f5 | ||
|
|
f69b729eb2 | ||
|
|
2ac47d5c85 | ||
|
|
26d3d84de0 | ||
|
|
b413935932 | ||
|
|
e6ae726304 | ||
|
|
520277b611 | ||
|
|
8cdfedddbd | ||
|
|
5312400a3f | ||
|
|
551f5abc4b | ||
|
|
10d826fc46 | ||
|
|
252bd6cf39 | ||
|
|
ba44dea7b6 | ||
|
|
b9c823e01a | ||
|
|
c108921dcf | ||
|
|
36eed1e091 | ||
|
|
897704fab3 | ||
|
|
9f70910283 | ||
|
|
3e3c9b97ae | ||
|
|
8c1ea11b95 | ||
|
|
cd0ab378ef | ||
|
|
5a27ae4862 | ||
|
|
05719642ca | ||
|
|
5c584536b5 | ||
|
|
4ba0db4e9e | ||
|
|
5e4ed9ee17 | ||
|
|
c399ff2bfa | ||
|
|
9e37a06514 | ||
|
|
294ce77a47 | ||
|
|
24c6b4a879 | ||
|
|
2c2696a8c3 | ||
|
|
479d1e7635 | ||
|
|
3a723460e5 | ||
|
|
8011756658 | ||
|
|
46546dac27 | ||
|
|
9a869a1474 | ||
|
|
09797695aa | ||
|
|
4f2cf45427 | ||
|
|
901eb7f469 | ||
|
|
91d12a7e97 | ||
|
|
e31c7351ea | ||
|
|
cf19fd41b0 | ||
|
|
500a441df7 |
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/*
|
||||
.github/*
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Don't track cargo generated files
|
||||
target/*
|
||||
@@ -17,6 +18,7 @@ Dockerfile
|
||||
*.md
|
||||
LICENSE
|
||||
CHANGELOG.md
|
||||
README.md
|
||||
docs/*
|
||||
example_configs/*
|
||||
|
||||
@@ -28,12 +30,24 @@ package.json
|
||||
# Pre-build binaries
|
||||
*.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
|
||||
.env
|
||||
lldap_config.toml
|
||||
server_key
|
||||
users.db*
|
||||
screenshot.png
|
||||
recipe.json
|
||||
lldap_config.toml
|
||||
cert.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:
|
||||
require_ci_to_pass: yes
|
||||
comment:
|
||||
layout: "diff,flags"
|
||||
layout: "header,diff,files"
|
||||
require_changes: true
|
||||
require_base: true
|
||||
require_head: true
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: "75%"
|
||||
threshold: "0.1%"
|
||||
removed_code_behavior: adjust_base
|
||||
github_checks:
|
||||
annotations: true
|
||||
ignore:
|
||||
- "app"
|
||||
- "docs"
|
||||
- "example_configs"
|
||||
- "migration-tool"
|
||||
- "scripts"
|
||||
- "set-password"
|
||||
|
||||
68
.github/workflows/Dockerfile.ci
vendored
68
.github/workflows/Dockerfile.ci
vendored
@@ -1,68 +0,0 @@
|
||||
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/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/
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/migration-tool /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
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
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini ca-certificates && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
WORKDIR /app
|
||||
USER $USER
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
30
.github/workflows/Dockerfile.ci.alpine
vendored
Normal file
30
.github/workflows/Dockerfile.ci.alpine
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM localhost:5000/lldap/lldap:alpine-base
|
||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
||||
ENV GOSU_VERSION 1.17
|
||||
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; \
|
||||
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
|
||||
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
|
||||
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
|
||||
31
.github/workflows/Dockerfile.ci.debian
vendored
Normal file
31
.github/workflows/Dockerfile.ci.debian
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM localhost:5000/lldap/lldap:debian-base
|
||||
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
|
||||
ENV GOSU_VERSION 1.17
|
||||
RUN set -eux; \
|
||||
# save list of currently installed packages for later so we can clean up
|
||||
savedAptMark="$(apt-mark showmanual)"; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ca-certificates gnupg wget; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
\
|
||||
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; \
|
||||
gpgconf --kill all; \
|
||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||
\
|
||||
# clean up fetch dependencies
|
||||
apt-mark auto '.*' > /dev/null; \
|
||||
[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; \
|
||||
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
|
||||
\
|
||||
chmod +x /usr/local/bin/gosu; \
|
||||
# verify that the binary works
|
||||
gosu --version; \
|
||||
gosu nobody true
|
||||
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
|
||||
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
|
||||
41
.github/workflows/Dockerfile.dev
vendored
Normal file
41
.github/workflows/Dockerfile.dev
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Keep tracking base image
|
||||
FROM rust:1.81-slim-bookworm
|
||||
|
||||
# Set needed env path
|
||||
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||
|
||||
# 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 && \
|
||||
apt install -y --no-install-recommends curl git wget make perl pkg-config tar jq gzip && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
### Add musl-gcc aarch64, x86_64 and armv7l
|
||||
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
|
||||
tar zxf ./x86_64-linux-musl-cross.tgz -C /opt && \
|
||||
wget -c https://musl.cc/aarch64-linux-musl-cross.tgz && \
|
||||
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 ./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"]
|
||||
742
.github/workflows/docker-build-static.yml
vendored
Normal file
742
.github/workflows/docker-build-static.yml
vendored
Normal file
@@ -0,0 +1,742 @@
|
||||
name: Docker Static
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'example_configs/**'
|
||||
release:
|
||||
types:
|
||||
- 'published'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'example_configs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
msg:
|
||||
description: "Set message"
|
||||
default: "Manual trigger"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
|
||||
### CI Docs
|
||||
|
||||
# build-ui , create/compile the web
|
||||
### install wasm
|
||||
### run app/build.sh
|
||||
### upload artifacts
|
||||
|
||||
# build-bin
|
||||
## build-armhf, build-aarch64, build-amd64 , create binary for respective arch
|
||||
#######################################################################################
|
||||
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
|
||||
# Look into .github/workflows/Dockerfile.dev for development image details #
|
||||
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
|
||||
# lldap/rust-dev #
|
||||
#######################################################################################
|
||||
# Cargo build
|
||||
### armv7, aarch64 and amd64 is musl based
|
||||
|
||||
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
||||
|
||||
# lldap-test
|
||||
### will run lldap with postgres, mariadb and sqlite backend, do selfcheck command.
|
||||
|
||||
# Build docker image
|
||||
### Triplet docker image arch with debian and alpine base
|
||||
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
|
||||
### Look into .github/workflows/Dockerfile.ci.debian or .github/workflowds/Dockerfile.ci.alpine
|
||||
|
||||
# Create release artifacts
|
||||
### Fetch artifacts
|
||||
### Clean up web artifact
|
||||
### Setup folder structure
|
||||
### Compress
|
||||
### Upload
|
||||
|
||||
# cache based on Cargo.lock per cargo target
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@master
|
||||
with:
|
||||
concurrent_skipping: 'outdated_runs'
|
||||
skip_after_successful_duplicate: ${{ github.ref != 'refs/heads/main' }}
|
||||
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".gitignore", "lldap_config.docker_template.toml"]'
|
||||
do_not_skip: '["workflow_dispatch", "schedule"]'
|
||||
cancel_others: true
|
||||
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||
container:
|
||||
image: lldap/rust-dev:v81
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/bin
|
||||
/usr/local/cargo/registry/index
|
||||
/usr/local/cargo/registry/cache
|
||||
/usr/local/cargo/git/db
|
||||
target
|
||||
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
lldap-ui-
|
||||
- name: Add wasm target (rust)
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
- name: Install wasm-pack with cargo
|
||||
run: cargo install wasm-pack || true
|
||||
env:
|
||||
RUSTFLAGS: ""
|
||||
- name: Build frontend
|
||||
run: ./app/build.sh
|
||||
- name: Check build path
|
||||
run: ls -al app/
|
||||
- name: Upload ui artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui
|
||||
path: app/
|
||||
|
||||
|
||||
build-bin:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [armv7-unknown-linux-musleabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
||||
container:
|
||||
image: lldap/rust-dev:v81
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cargo/bin
|
||||
.cargo/registry/index
|
||||
.cargo/registry/cache
|
||||
.cargo/git/db
|
||||
target
|
||||
key: lldap-bin-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
lldap-bin-${{ matrix.target }}-
|
||||
- name: Compile ${{ matrix.target }} lldap and tools
|
||||
run: cargo build --target=${{ matrix.target }} --release -p lldap -p lldap_migration_tool -p lldap_set_password
|
||||
- name: Check path
|
||||
run: ls -al target/release
|
||||
- name: Upload ${{ matrix.target}} lldap artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target}}-lldap-bin
|
||||
path: target/${{ matrix.target }}/release/lldap
|
||||
- name: Upload ${{ matrix.target }} migration tool artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}-lldap_migration_tool-bin
|
||||
path: target/${{ matrix.target }}/release/lldap_migration_tool
|
||||
- name: Upload ${{ matrix.target }} password tool artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}-lldap_set_password-bin
|
||||
path: target/${{ matrix.target }}/release/lldap_set_password
|
||||
|
||||
lldap-database-init-test:
|
||||
needs: [build-ui,build-bin]
|
||||
name: LLDAP database init test
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
ports:
|
||||
- 3306:3306
|
||||
env:
|
||||
MARIADB_USER: lldapuser
|
||||
MARIADB_PASSWORD: lldappass
|
||||
MARIADB_DATABASE: lldap
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
||||
options: >-
|
||||
--name mariadb
|
||||
--health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
postgresql:
|
||||
image: postgres:latest
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_USER: lldapuser
|
||||
POSTGRES_PASSWORD: lldappass
|
||||
POSTGRES_DB: lldap
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--name postgresql
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap-bin
|
||||
path: bin/
|
||||
|
||||
- name: Set executables to LLDAP
|
||||
run: chmod +x bin/lldap
|
||||
|
||||
- name: Run lldap with postgres DB and healthcheck
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
|
||||
LLDAP_ldap_port: 3890
|
||||
LLDAP_http_port: 17170
|
||||
|
||||
|
||||
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
|
||||
LLDAP_ldap_port: 3891
|
||||
LLDAP_http_port: 17171
|
||||
|
||||
|
||||
- name: Run lldap with sqlite DB and healthcheck
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||
LLDAP_ldap_port: 3892
|
||||
LLDAP_http_port: 17172
|
||||
|
||||
- name: Check DB container logs
|
||||
run: |
|
||||
docker logs -n 20 mariadb
|
||||
docker logs -n 20 postgresql
|
||||
|
||||
lldap-database-migration-test:
|
||||
needs: [build-ui,build-bin]
|
||||
name: LLDAP database migration test
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:latest
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_USER: lldapuser
|
||||
POSTGRES_PASSWORD: lldappass
|
||||
POSTGRES_DB: lldap
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--name postgresql
|
||||
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
ports:
|
||||
- 3306:3306
|
||||
env:
|
||||
MARIADB_USER: lldapuser
|
||||
MARIADB_PASSWORD: lldappass
|
||||
MARIADB_DATABASE: lldap
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
||||
options: >-
|
||||
--name mariadb
|
||||
--health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
|
||||
mysql:
|
||||
image: mysql:latest
|
||||
ports:
|
||||
- 3307:3306
|
||||
env:
|
||||
MYSQL_USER: lldapuser
|
||||
MYSQL_PASSWORD: lldappass
|
||||
MYSQL_DATABASE: lldap
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
||||
options: >-
|
||||
--name mysql
|
||||
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
sparse-checkout: 'scripts'
|
||||
|
||||
- name: Download LLDAP artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap-bin
|
||||
path: bin/
|
||||
|
||||
- name: Download LLDAP set password
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap_set_password-bin
|
||||
path: bin/
|
||||
|
||||
- name: Set executables to LLDAP and LLDAP set password
|
||||
run: |
|
||||
chmod +x bin/lldap
|
||||
chmod +x bin/lldap_set_password
|
||||
|
||||
- name: Install sqlite3 and ldap-utils for exporting and searching dummy user
|
||||
run: sudo apt update && sudo apt install -y sqlite3 ldap-utils
|
||||
|
||||
- name: Run lldap with sqlite DB and healthcheck
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||
LLDAP_ldap_port: 3890
|
||||
LLDAP_http_port: 17170
|
||||
LLDAP_LDAP_USER_PASS: ldappass
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Create dummy user
|
||||
run: |
|
||||
TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "ldappass"}' http://localhost:17170/auth/simple/login | jq -r .token)
|
||||
echo "$TOKEN"
|
||||
curl 'http://localhost:17170/api/graphql' -H 'Content-Type: application/json' -H "Authorization: Bearer ${TOKEN//[$'\t\r\n ']}" --data-binary '{"query":"mutation{\n createUser(user:\n {\n id: \"dummyuser\",\n email: \"dummyuser@example.com\"\n }\n )\n {\n id\n email\n }\n}\n\n\n"}' --compressed
|
||||
bin/lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password ldappass --token $TOKEN --username dummyuser --password dummypassword
|
||||
|
||||
- name: Test Dummy User, This will be checked again after importing
|
||||
run: |
|
||||
ldapsearch -H ldap://localhost:3890 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
|
||||
- name: Stop LLDAP sqlite
|
||||
run: pkill lldap
|
||||
|
||||
- name: Export and Converting to Postgress
|
||||
run: |
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e ":a; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\3/; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\3/; ta" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
|
||||
- name: Create schema on postgres
|
||||
run: |
|
||||
bin/lldap create_schema -d postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
|
||||
- name: Copy converted db to postgress and import
|
||||
run: |
|
||||
docker cp ./dump.sql postgresql:/tmp/dump.sql
|
||||
docker exec postgresql bash -c "psql -U lldapuser -d lldap < /tmp/dump.sql" | tee import.log
|
||||
rm ./dump.sql
|
||||
! grep ERROR import.log > /dev/null
|
||||
|
||||
- name: Export and Converting to mariadb
|
||||
run: |
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
cp ./dump.sql ./dump-no-sed.sql
|
||||
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||
|
||||
- name: Create schema on mariadb
|
||||
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
|
||||
- name: Copy converted db to mariadb and import
|
||||
run: |
|
||||
docker cp ./dump.sql mariadb:/tmp/dump.sql
|
||||
docker exec mariadb bash -c "mariadb -ulldapuser -plldappass -f lldap < /tmp/dump.sql" | tee import.log
|
||||
rm ./dump.sql
|
||||
! grep ERROR import.log > /dev/null
|
||||
|
||||
- name: Export and Converting to mysql
|
||||
run: |
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||
|
||||
- name: Create schema on mysql
|
||||
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
|
||||
- name: Copy converted db to mysql and import
|
||||
run: |
|
||||
docker cp ./dump.sql mysql:/tmp/dump.sql
|
||||
docker exec mysql bash -c "mysql -ulldapuser -plldappass -f lldap < /tmp/dump.sql" | tee import.log
|
||||
rm ./dump.sql
|
||||
! grep ERROR import.log > /dev/null
|
||||
|
||||
- name: Run lldap with postgres DB and healthcheck again
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
LLDAP_ldap_port: 3891
|
||||
LLDAP_http_port: 17171
|
||||
LLDAP_LDAP_USER_PASS: ldappass
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Run lldap with mariaDB and healthcheck again
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
LLDAP_ldap_port: 3892
|
||||
LLDAP_http_port: 17172
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Run lldap with mysql and healthcheck again
|
||||
run: |
|
||||
bin/lldap run &
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
LLDAP_ldap_port: 3893
|
||||
LLDAP_http_port: 17173
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Test Dummy User Postgres
|
||||
run: ldapsearch -H ldap://localhost:3891 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
- name: Test Dummy User MariaDB
|
||||
run: ldapsearch -H ldap://localhost:3892 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
- name: Test Dummy User MySQL
|
||||
run: ldapsearch -H ldap://localhost:3893 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
|
||||
########################################
|
||||
#### BUILD BASE IMAGE ##################
|
||||
########################################
|
||||
build-docker-image:
|
||||
needs: [build-ui, build-bin]
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
container: ["debian","alpine"]
|
||||
include:
|
||||
- container: alpine
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{version}},suffix=
|
||||
type=semver,pattern=v{{major}},suffix=
|
||||
type=semver,pattern=v{{major}}.{{minor}},suffix=
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }},suffix=
|
||||
type=raw,value=latest,enable={{ is_default_branch }},suffix=
|
||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }}
|
||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }},suffix=
|
||||
- container: debian
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }}
|
||||
services:
|
||||
registry:
|
||||
image: registry:2
|
||||
ports:
|
||||
- 5000:5000
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: bin
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Setup buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Docker ${{ matrix.container }} Base meta
|
||||
id: meta-base
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
localhost:5000/lldap/lldap
|
||||
tags: ${{ matrix.container }}-base
|
||||
|
||||
- name: Build ${{ matrix.container }} Base Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
# On PR will fail, force fully uncomment push: true, or docker image will fail for next steps
|
||||
#push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}-base
|
||||
tags: |
|
||||
${{ steps.meta-base.outputs.tags }}
|
||||
labels: ${{ steps.meta-base.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
#####################################
|
||||
#### build variants docker image ####
|
||||
#####################################
|
||||
|
||||
- name: Docker ${{ matrix.container }}-rootless meta
|
||||
id: meta-rootless
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
lldap/lldap
|
||||
ghcr.io/lldap/lldap
|
||||
# Wanted Docker tags
|
||||
# vX-alpine
|
||||
# vX.Y-alpine
|
||||
# vX.Y.Z-alpine
|
||||
# latest
|
||||
# latest-alpine
|
||||
# stable
|
||||
# stable-alpine
|
||||
# YYYY-MM-DD
|
||||
# YYYY-MM-DD-alpine
|
||||
#################
|
||||
# vX-debian
|
||||
# vX.Y-debian
|
||||
# vX.Y.Z-debian
|
||||
# latest-debian
|
||||
# stable-debian
|
||||
# YYYY-MM-DD-debian
|
||||
#################
|
||||
# Check matrix for tag list definition
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-${{ matrix.container }}-rootless
|
||||
tags: ${{ matrix.tags }}
|
||||
|
||||
- name: Docker ${{ matrix.container }} meta
|
||||
id: meta-standard
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
lldap/lldap
|
||||
ghcr.io/lldap/lldap
|
||||
# Wanted Docker tags
|
||||
# vX-alpine
|
||||
# vX.Y-alpine
|
||||
# vX.Y.Z-alpine
|
||||
# latest
|
||||
# latest-alpine
|
||||
# stable
|
||||
# stable-alpine
|
||||
# YYYY-MM-DD
|
||||
# YYYY-MM-DD-alpine
|
||||
#################
|
||||
# vX-debian
|
||||
# vX.Y-debian
|
||||
# vX.Y.Z-debian
|
||||
# latest-debian
|
||||
# stable-debian
|
||||
# YYYY-MM-DD-debian
|
||||
#################
|
||||
# Check matrix for tag list definition
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-${{ matrix.container }}
|
||||
tags: ${{ matrix.tags }}
|
||||
|
||||
# Docker login to nitnelave/lldap and lldap/lldap
|
||||
- name: Login to Nitnelave/LLDAP Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: nitnelave
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build ${{ matrix.container }}-rootless Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}-rootless
|
||||
tags: |
|
||||
${{ steps.meta-rootless.outputs.tags }}
|
||||
labels: ${{ steps.meta-rootless.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
|
||||
- name: Build ${{ matrix.container }} Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}
|
||||
tags: |
|
||||
${{ steps.meta-standard.outputs.tags }}
|
||||
labels: ${{ steps.meta-standard.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: nitnelave/lldap
|
||||
|
||||
- name: Update lldap repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: lldap/lldap
|
||||
|
||||
###############################################################
|
||||
### Download artifacts, clean up ui, upload to release page ###
|
||||
###############################################################
|
||||
create-release-artifacts:
|
||||
needs: [build-ui, build-bin]
|
||||
name: Create release artifacts
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: bin/
|
||||
- name: Check file
|
||||
run: ls -alR bin/
|
||||
- name: Fixing Filename
|
||||
run: |
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap bin/aarch64-lldap
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap bin/amd64-lldap
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap bin/armhf-lldap
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/aarch64-lldap_migration_tool
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/amd64-lldap_migration_tool
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool bin/armhf-lldap_migration_tool
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/aarch64-lldap_set_password
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/amd64-lldap_set_password
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
|
||||
chmod +x bin/*-lldap
|
||||
chmod +x bin/*-lldap_migration_tool
|
||||
chmod +x bin/*-lldap_set_password
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
- name: UI (web) artifacts cleanup
|
||||
run: mkdir app && mv web/index.html app/index.html && mv web/static app/static && mv web/pkg app/pkg
|
||||
- name: Fetch web components
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install wget
|
||||
for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done
|
||||
for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done
|
||||
chmod a+r -R .
|
||||
|
||||
- name: Setup LLDAP dir for packing
|
||||
run: |
|
||||
mkdir aarch64-lldap
|
||||
mkdir amd64-lldap
|
||||
mkdir armhf-lldap
|
||||
mv bin/aarch64-lldap aarch64-lldap/lldap
|
||||
mv bin/amd64-lldap amd64-lldap/lldap
|
||||
mv bin/armhf-lldap armhf-lldap/lldap
|
||||
mv bin/aarch64-lldap_migration_tool aarch64-lldap/lldap_migration_tool
|
||||
mv bin/amd64-lldap_migration_tool amd64-lldap/lldap_migration_tool
|
||||
mv bin/armhf-lldap_migration_tool armhf-lldap/lldap_migration_tool
|
||||
mv bin/aarch64-lldap_set_password aarch64-lldap/lldap_set_password
|
||||
mv bin/amd64-lldap_set_password amd64-lldap/lldap_set_password
|
||||
mv bin/armhf-lldap_set_password armhf-lldap/lldap_set_password
|
||||
cp -r app aarch64-lldap/
|
||||
cp -r app amd64-lldap/
|
||||
cp -r app armhf-lldap/
|
||||
ls -alR aarch64-lldap/
|
||||
ls -alR amd64-lldap/
|
||||
ls -alR armhf-lldap/
|
||||
|
||||
- name: Packing LLDAP and Web UI
|
||||
run: |
|
||||
tar -czvf aarch64-lldap.tar.gz aarch64-lldap/
|
||||
tar -czvf amd64-lldap.tar.gz amd64-lldap/
|
||||
tar -czvf armhf-lldap.tar.gz armhf-lldap/
|
||||
|
||||
|
||||
- name: Upload compressed release
|
||||
uses: ncipollo/release-action@v1
|
||||
id: create_release
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: aarch64-lldap.tar.gz,
|
||||
amd64-lldap.tar.gz,
|
||||
armhf-lldap.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
410
.github/workflows/docker-build.yml
vendored
410
.github/workflows/docker-build.yml
vendored
@@ -1,410 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
release:
|
||||
types:
|
||||
- 'published'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
msg:
|
||||
description: "Set message"
|
||||
default: "Manual trigger"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: $GITHUB_WORKSPACE/.sccache
|
||||
SCCACHE_VERSION: v0.3.0
|
||||
LINK: https://github.com/mozilla/sccache/releases/download
|
||||
|
||||
# In total 5 jobs, all of the jobs are containerized
|
||||
# ---
|
||||
|
||||
# build-ui , create/compile the web
|
||||
## Use rustlang/rust:nighlty image
|
||||
### Install nodejs from nodesource repo
|
||||
### install wasm
|
||||
### install rollup
|
||||
### run app/build.sh
|
||||
### upload artifacts
|
||||
|
||||
# builds-armhf, build-aarch64, build-amd64 create binary for respective arch
|
||||
## Use rustlang/rust:nightly image
|
||||
### Add non native architecture dpkg --add-architecture XXX
|
||||
### Install dev tool gcc g++, etc per respective arch
|
||||
### Cargo build
|
||||
### Upload artifacts
|
||||
|
||||
## the CARGO_ env
|
||||
#CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
#OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
|
||||
#OPENSSL_LIB_DIR: "/usr/lib/arm-linux-gnueabihf/"
|
||||
# This will determine which architecture lib will be used.
|
||||
|
||||
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
||||
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
|
||||
|
||||
# On current https://hub.docker.com/_/rust
|
||||
# 1-bullseye, 1.61-bullseye, 1.61.0-bullseye, bullseye, 1, 1.61, 1.61.0, latest
|
||||
|
||||
# cache
|
||||
## .sccache
|
||||
## cargo
|
||||
## target
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev libssl-dev
|
||||
- name: setup node repo LTS
|
||||
run: curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
||||
- name: install nodejs
|
||||
run: apt install -y nodejs && npm -g install npm
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-ui-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-ui-
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: install cargo wasm
|
||||
run: cargo install wasm-pack
|
||||
- name: install rollup nodejs
|
||||
run: npm install -g rollup
|
||||
- name: build frontend
|
||||
run: ./app/build.sh
|
||||
- name: check path
|
||||
run: ls -al app/
|
||||
- name: upload ui artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: app/
|
||||
|
||||
build-armhf:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
|
||||
OPENSSL_LIB_DIR: "/usr/lib/arm-linux-gnueabihf/"
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: add armhf architecture
|
||||
run: dpkg --add-architecture armhf
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross libssl-dev:armhf tar
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: add armhf target
|
||||
run: rustup target add armv7-unknown-linux-gnueabihf
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-bin-armhf-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-bin-armhf-
|
||||
- name: compile armhf
|
||||
run: cargo build --target=armv7-unknown-linux-gnueabihf --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/release
|
||||
- name: upload armhf lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: armhf-lldap-bin
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/lldap
|
||||
- name: upload armhfmigration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: armhf-migration-tool-bin
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/migration-tool
|
||||
|
||||
|
||||
build-aarch64:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
|
||||
OPENSSL_LIB_DIR: "/usr/lib/aarch64-linux-gnu/"
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: add arm64 architecture
|
||||
run: dpkg --add-architecture arm64
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross libssl-dev:arm64 tar
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: add arm64 target
|
||||
run: rustup target add aarch64-unknown-linux-gnu
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-bin-aarch64-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-bin-aarch64-
|
||||
- name: compile aarch64
|
||||
run: cargo build --target=aarch64-unknown-linux-gnu --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/aarch64-unknown-linux-gnu/release/
|
||||
- name: upload aarch64 lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aarch64-lldap-bin
|
||||
path: target/aarch64-unknown-linux-gnu/release/lldap
|
||||
- name: upload aarch64 migration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aarch64-migration-tool-bin
|
||||
path: target/aarch64-unknown-linux-gnu/release/migration-tool
|
||||
|
||||
build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev libssl-dev tar
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: cargo & sscache cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-bin-amd64-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-bin-amd64-
|
||||
#- name: add cargo chef
|
||||
# run: cargo install cargo-chef
|
||||
#- name: chef prepare
|
||||
# run: cargo chef prepare --recipe-path recipe.json
|
||||
#- name: cook?
|
||||
# run: cargo chef cook --release --recipe-path recipe.json
|
||||
- name: compile amd64
|
||||
run: cargo build --target=x86_64-unknown-linux-gnu --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/x86_64-unknown-linux-gnu/release/
|
||||
- name: upload amd64 lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: amd64-lldap-bin
|
||||
path: target/x86_64-unknown-linux-gnu/release/lldap
|
||||
- name: upload amd64 migration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: amd64-migration-tool-bin
|
||||
path: target/x86_64-unknown-linux-gnu/release/migration-tool
|
||||
|
||||
|
||||
build-docker-image:
|
||||
needs: [build-ui,build-armhf,build-aarch64,build-amd64]
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: fetch repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Download armhf lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-lldap-bin
|
||||
path: bin/armhf-bin
|
||||
- name: Download armhf migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-migration-tool-bin
|
||||
path: bin/armhf-bin
|
||||
|
||||
- name: Download aarch64 lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: aarch64-lldap-bin
|
||||
path: bin/aarch64-bin
|
||||
- name: Download aarch64 migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: aarch64-migration-tool-bin
|
||||
path: bin/aarch64-bin
|
||||
|
||||
- name: Download amd64 lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: amd64-lldap-bin
|
||||
path: bin/amd64-bin
|
||||
- name: Download amd64 migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: amd64-migration-tool-bin
|
||||
path: bin/amd64-bin
|
||||
|
||||
- name: check bin path
|
||||
run: ls -al bin/
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
|
||||
- name: setup qemu
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: parse tag
|
||||
uses: gacts/github-slug@v1
|
||||
id: slug
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push latest
|
||||
if: github.event_name != 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./.github/workflows/Dockerfile.ci
|
||||
tags: nitnelave/lldap:latest
|
||||
#cache-from: type=gha
|
||||
#cache-to: type=gha,mode=max
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Build and push release
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
|
||||
file: ./.github/workflows/Dockerfile.ci
|
||||
tags: nitnelave/lldap:stable, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}
|
||||
#cache-from: type=gha
|
||||
#cache-to: type=gha,mode=max
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
- name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: nitnelave/lldap
|
||||
|
||||
29
.github/workflows/rust.yml
vendored
29
.github/workflows/rust.yml
vendored
@@ -13,7 +13,6 @@ jobs:
|
||||
pre_job:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
steps:
|
||||
@@ -22,7 +21,7 @@ jobs:
|
||||
with:
|
||||
concurrent_skipping: 'outdated_runs'
|
||||
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"]'
|
||||
cancel_others: true
|
||||
|
||||
@@ -34,8 +33,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
uses: actions/checkout@v4.2.2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
- name: Run tests
|
||||
@@ -53,9 +52,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
@@ -70,9 +69,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo fmt
|
||||
uses: actions-rs/cargo@v1
|
||||
@@ -82,26 +81,30 @@ jobs:
|
||||
|
||||
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') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
run: cargo llvm-cov --workspace --no-report
|
||||
- name: Aggregate reports
|
||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
- name: Upload coverage to Codecov (main)
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: lcov.info
|
||||
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
|
||||
|
||||
# Misc
|
||||
.vscode
|
||||
.env
|
||||
recipe.json
|
||||
lldap_config.toml
|
||||
|
||||
284
CHANGELOG.md
284
CHANGELOG.md
@@ -5,7 +5,289 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for STARTTLS for SMTP.
|
||||
- Added support for user profile pictures, including importing them from OpenLDAP.
|
||||
- Added support for every config value to be specified in a file.
|
||||
- Added support for PKCS1 keys.
|
||||
|
||||
### Changed
|
||||
|
||||
- The `dn` attribute is no longer returned as an attribute (it's still part of the response).
|
||||
- Empty attributes are no longer returned.
|
||||
- The docker image now uses the locally-downloaded assets.
|
||||
|
||||
## [0.4.0] - 2022-07-08
|
||||
|
||||
### Breaking
|
||||
|
||||
The `lldap_readonly` group has been renamed `lldap_password_manager` (migration happens automatically) and a new `lldap_strict_readonly` group was introduced.
|
||||
|
||||
### Added
|
||||
- A new `lldap_strict_readonly` group allows granting readonly rights to users (not able to change other's passwords, in particular).
|
||||
|
||||
### Changed
|
||||
- The `lldap_readonly` group is renamed `lldap_password_manager` since it still allows users to change (non-admin) passwords.
|
||||
|
||||
### Removed
|
||||
- The `lldap_readonly` group was removed.
|
||||
|
||||
## [0.3.0] - 2022-07-08
|
||||
|
||||
|
||||
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.
|
||||
4233
Cargo.lock
generated
4233
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
19
Cargo.toml
@@ -3,16 +3,19 @@ members = [
|
||||
"server",
|
||||
"auth",
|
||||
"app",
|
||||
"migration-tool"
|
||||
"migration-tool",
|
||||
"set-password",
|
||||
]
|
||||
|
||||
default-members = ["server"]
|
||||
|
||||
# TODO: remove when there's a new release.
|
||||
[patch.crates-io.yew_form]
|
||||
git = 'https://github.com/sassman/yew_form/'
|
||||
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
|
||||
resolver = "2"
|
||||
|
||||
[patch.crates-io.yew_form_derive]
|
||||
git = 'https://github.com/sassman/yew_form/'
|
||||
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
|
||||
[profile.release]
|
||||
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
|
||||
FROM rust:alpine3.14 AS chef
|
||||
FROM rust:alpine3.16 AS chef
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
@@ -11,7 +11,7 @@ RUN set -x \
|
||||
--uid 10001 \
|
||||
app \
|
||||
# Install required packages
|
||||
&& apk add npm openssl-dev musl-dev make perl curl
|
||||
&& apk add openssl-dev musl-dev make perl curl gzip
|
||||
|
||||
USER app
|
||||
WORKDIR /app
|
||||
@@ -19,7 +19,6 @@ WORKDIR /app
|
||||
RUN set -x \
|
||||
# Install build tools
|
||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||
&& npm install rollup \
|
||||
&& rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Prepare the dependency list.
|
||||
@@ -32,27 +31,59 @@ FROM chef AS builder
|
||||
COPY --from=planner /tmp/recipe.json recipe.json
|
||||
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||
&& cargo chef cook --release -p lldap \
|
||||
&& cargo chef cook --release -p migration-tool
|
||||
&& cargo chef cook --release -p lldap_migration_tool \
|
||||
&& cargo chef cook --release -p lldap_set_password
|
||||
|
||||
# Copy the source and build the app and server.
|
||||
COPY --chown=app:app . .
|
||||
RUN cargo build --release -p lldap -p migration-tool \
|
||||
RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password \
|
||||
# Build the frontend.
|
||||
&& ./app/build.sh
|
||||
|
||||
# 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
|
||||
|
||||
COPY --from=builder /app/app/index_local.html app/index.html
|
||||
COPY --from=builder /app/app/static app/static
|
||||
COPY --from=builder /app/app/pkg app/pkg
|
||||
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool ./
|
||||
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||
COPY scripts/bootstrap.sh ./
|
||||
|
||||
RUN set -x \
|
||||
&& apk add --no-cache bash \
|
||||
&& 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/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
@@ -64,3 +95,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
|
||||
648
README.md
648
README.md
@@ -5,14 +5,15 @@
|
||||
</p>
|
||||
|
||||
<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
|
||||
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"/>
|
||||
</a>
|
||||
<a href="https://discord.gg/h5PEdRMNyP">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
|
||||
</a>
|
||||
|
||||
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
|
||||
<img
|
||||
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
|
||||
@@ -23,25 +24,40 @@
|
||||
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
|
||||
alt="Unsafe forbidden"/>
|
||||
</a>
|
||||
<a href="https://app.codecov.io/gh/nitnelave/lldap">
|
||||
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/nitnelave/lldap" />
|
||||
<a href="https://app.codecov.io/gh/lldap/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>
|
||||
</p>
|
||||
|
||||
- [About](#About)
|
||||
- [Installation](#Installation)
|
||||
- [With Docker](#With-Docker)
|
||||
- [From source](#From-source)
|
||||
- [Cross-compilation](#Cross-compilation)
|
||||
- [Client configuration](#Client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Sample cient configurations](#Sample-client-configurations)
|
||||
- [Comparisons with other services](#Comparisons-with-other-services)
|
||||
- [vs OpenLDAP](#vs-openldap)
|
||||
- [vs FreeIPA](#vs-freeipa)
|
||||
- [I can't log in!](#i-cant-log-in)
|
||||
- [Contributions](#Contributions)
|
||||
- [About](#about)
|
||||
- [Installation](#installation)
|
||||
- [With Docker](#with-docker)
|
||||
- [With Kubernetes](#with-kubernetes)
|
||||
- [From a package repository](#from-a-package-repository)
|
||||
- [With FreeBSD](#with-freebsd)
|
||||
- [From source](#from-source)
|
||||
- [Backend](#backend)
|
||||
- [Frontend](#frontend)
|
||||
- [Cross-compilation](#cross-compilation)
|
||||
- [Usage](#usage)
|
||||
- [Recommended architecture](#recommended-architecture)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [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
|
||||
|
||||
@@ -51,7 +67,7 @@ many backends, from KeyCloak to Authelia to Nextcloud and
|
||||
[more](#compatible-services)!
|
||||
|
||||
<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"
|
||||
width="50%"
|
||||
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,
|
||||
check out OpenLDAP. This server is a user management system that is:
|
||||
* simple to setup (no messing around with `slapd`),
|
||||
* simple to manage (friendly web UI),
|
||||
* low resources,
|
||||
* opinionated with basic defaults so you don't have to understand the
|
||||
|
||||
- simple to setup (no messing around with `slapd`),
|
||||
- simple to manage (friendly web UI),
|
||||
- low resources,
|
||||
- opinionated with basic defaults so you don't have to understand the
|
||||
subtleties of LDAP.
|
||||
|
||||
It mostly targets self-hosting servers, with open-source components like
|
||||
@@ -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
|
||||
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
|
||||
|
||||
### With Docker
|
||||
|
||||
The image is available at `nitnelave/lldap`. You should persist the `/data`
|
||||
folder, which contains your configuration, the database and the private key
|
||||
file.
|
||||
The image is available at `lldap/lldap`. You should persist the `/data`
|
||||
folder, which contains your configuration and the SQLite database (you can
|
||||
remove this step if you use a different DB and configure with environment
|
||||
variables only).
|
||||
|
||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||
@@ -90,26 +111,38 @@ Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
Environment variables should be prefixed with `LLDAP_` to override the
|
||||
configuration.
|
||||
|
||||
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use
|
||||
default one. The default admin password is `password`, you can change the
|
||||
password later using the web interface.
|
||||
|
||||
Secrets can also be set through a file. The filename should be specified by the
|
||||
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_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
|
||||
`_FILE` variables take precedence.
|
||||
|
||||
Example for docker compose:
|
||||
|
||||
- You can use either the `:latest` tag image or `:stable` as used in this example.
|
||||
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
|
||||
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
|
||||
- If no `TZ` is set, default `UTC` timezone will be used.
|
||||
- You can generate the secrets by running `./generate_secrets.sh`
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/lldap:stable
|
||||
# Change this to the user:group you want.
|
||||
user: "33:33"
|
||||
image: lldap/lldap:stable
|
||||
ports:
|
||||
# For LDAP
|
||||
- "3890:3890"
|
||||
# For LDAP, not recommended to expose, see Usage section.
|
||||
#- "3890:3890"
|
||||
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
|
||||
#- "6360:6360"
|
||||
# For the web front-end
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
@@ -117,29 +150,309 @@ services:
|
||||
# Alternatively, you can mount a local folder
|
||||
# - "./lldap_data:/data"
|
||||
environment:
|
||||
- UID=####
|
||||
- GID=####
|
||||
- TZ=####/####
|
||||
- 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
|
||||
# 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
|
||||
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</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>
|
||||
Maintainer: <a href="https://github.com/Zepmann">@Zepmann</a><br>
|
||||
Support: <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
|
||||
Package repository: <a href="https://aur.archlinux.org/packages">Arch user repository</a><br>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Available packages:</td>
|
||||
<td><a href="https://aur.archlinux.org/packages/lldap">lldap</a></td>
|
||||
<td>Builds the latest stable version.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><a href="https://aur.archlinux.org/packages/lldap-bin">lldap-bin</a></td>
|
||||
<td>Uses the latest pre-compiled binaries from the <a href="https://aur.archlinux.org/packages/lldap-bin">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></td>
|
||||
<td><a href="https://aur.archlinux.org/packages/lldap-git">lldap-git</a></td>
|
||||
<td>Builds the latest main branch code.</td>
|
||||
</tr>
|
||||
</table>
|
||||
LLDPA 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
|
||||
|
||||
#### Backend
|
||||
|
||||
To compile the project, you'll need:
|
||||
|
||||
- curl and gzip: `sudo apt install curl gzip`
|
||||
- Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
||||
|
||||
Then you can compile the server (and the migration tool if you want):
|
||||
|
||||
```shell
|
||||
cargo build --release -p lldap -p lldap_migration_tool
|
||||
```
|
||||
|
||||
The resulting binaries will be in `./target/release/`. Alternatively, you can
|
||||
just run `cargo run -- run` to run the server.
|
||||
|
||||
#### Frontend
|
||||
|
||||
To bring up the server, you'll need to compile the frontend. In addition to
|
||||
cargo, you'll need:
|
||||
`cargo`, you'll need WASM-pack, which can be installed by running `cargo install wasm-pack`.
|
||||
|
||||
* WASM-pack: `cargo install wasm-pack`
|
||||
* rollup.js: `npm install rollup`
|
||||
Then you can build the frontend files with
|
||||
|
||||
Then you can build the frontend files with `./app/build.sh` (you'll need to run
|
||||
this after every front-end change to update the WASM package served).
|
||||
```shell
|
||||
./app/build.sh
|
||||
```
|
||||
|
||||
To bring up the server, just run `cargo run`. The default config is in
|
||||
`src/infra/configuration.rs`, but you can override it by creating an
|
||||
`lldap_config.toml`, setting environment variables or passing arguments to
|
||||
`cargo run`.
|
||||
(you'll need to run this after every front-end change to update the WASM
|
||||
package served).
|
||||
|
||||
The default config is in `src/infra/configuration.rs`, but you can override it
|
||||
by creating an `lldap_config.toml`, setting environment variables or passing
|
||||
arguments to `cargo run`. Have a look at the docker template:
|
||||
`lldap_config.docker_template.toml`.
|
||||
|
||||
You can also install it as a systemd service, see
|
||||
[lldap.service](example_configs/lldap.service).
|
||||
|
||||
### Cross-compilation
|
||||
|
||||
@@ -163,6 +476,53 @@ You can then get the compiled server binary in
|
||||
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||
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
|
||||
|
||||
### Compatible services
|
||||
@@ -176,97 +536,191 @@ the config).
|
||||
### General configuration guide
|
||||
|
||||
To configure the services that will talk to LLDAP, here are the values:
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
- The LDAP password is from the configuration (same as to log in to the web
|
||||
UI).
|
||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
- The LDAP password is from the configuration (same as to log in to the web
|
||||
UI).
|
||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||
|
||||
Testing group membership through `memberOf` is supported, so you can have a
|
||||
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||
|
||||
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
||||
admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
||||
the `lldap_readonly` group, to avoid granting full administration access to
|
||||
many services.
|
||||
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||
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
|
||||
|
||||
Some specific clients have been tested to work and come with sample
|
||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||
folder for help with:
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [Dolibarr](example_configs/dolibarr.md)
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jisti Meet](example_configs/jitsi_meet.conf)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [WG Portal](example_configs/wg_portal.env.example)
|
||||
|
||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Apereo CAS Server](example_configs/apereo_cas_server.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Authentik](example_configs/authentik.md)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [Carpal](example_configs/carpal.md)
|
||||
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||
- [Dex](example_configs/dex_config.yml)
|
||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||
- [Dolibarr](example_configs/dolibarr.md)
|
||||
- [Ejabberd](example_configs/ejabberd.md)
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Ergo IRCd](example_configs/ergo.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [GitLab](example_configs/gitlab.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Grocy](example_configs/grocy.md)
|
||||
- [Harbor](example_configs/harbor.md)
|
||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||
- [Home Assistant](example_configs/home-assistant.md)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jenkins](example_configs/jenkins.md)
|
||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||
- [Kasm](example_configs/kasm.md)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [LibreNMS](example_configs/librenms.md)
|
||||
- [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
|
||||
|
||||
### vs OpenLDAP
|
||||
|
||||
OpenLDAP is a monster of a service that implements all of LDAP and all of its
|
||||
extensions, plus some of its own. That said, if you need all that flexibility,
|
||||
it might be what you need! Note that installation can be a bit painful
|
||||
(figuring out how to use `slapd`) and people have mixed experiences following
|
||||
tutorials online. If you don't configure it properly, you might end up storing
|
||||
passwords in clear, so a breach of your server would reveal all the stored
|
||||
passwords!
|
||||
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
|
||||
all of LDAP and all of its extensions, plus some of its own. That said, if you
|
||||
need all that flexibility, it might be what you need! Note that installation
|
||||
can be a bit painful (figuring out how to use `slapd`) and people have mixed
|
||||
experiences following tutorials online. If you don't configure it properly, you
|
||||
might end up storing passwords in clear, so a breach of your server would
|
||||
reveal all the stored passwords!
|
||||
|
||||
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
||||
install one (not that many that look nice) and configure it.
|
||||
install one (not that many look nice) and configure it.
|
||||
|
||||
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
||||
you add PhpLdapAdmin), and comes packed with its own purpose-built wed UI.
|
||||
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
|
||||
However, it's not as flexible as OpenLDAP.
|
||||
|
||||
### vs FreeIPA
|
||||
|
||||
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
|
||||
Samba, you name it, it has it. In addition to user management, it also does
|
||||
security policies, single sign-on, certificate management, linux account
|
||||
management and so on.
|
||||
[FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
|
||||
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||
management, it also does security policies, single sign-on, certificate
|
||||
management, linux account management and so on.
|
||||
|
||||
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||
more complex to maintain, though.
|
||||
|
||||
LLDAP is much lighter to run (<100 MB RAM including the DB), easier to
|
||||
LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
|
||||
configure (no messing around with DNS or security policies) and simpler to
|
||||
use. It also comes conveniently packed in a docker container.
|
||||
|
||||
### vs Kanidm
|
||||
|
||||
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
|
||||
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
|
||||
WebAuthn. It comes with a (read-only) LDAPS server.
|
||||
|
||||
It's fairly easy to install and does much more; but their LDAP server is
|
||||
read-only, and by having more moving parts it is inherently more complex. If
|
||||
you don't need to modify the users through LDAP and you're planning on
|
||||
installing something like [KeyCloak](https://www.keycloak.org) to provide
|
||||
modern identity protocols, check out Kanidm.
|
||||
|
||||
## I can't log in!
|
||||
|
||||
If you just set up the server, can get to the login page but the password you
|
||||
set isn't working, try the following:
|
||||
|
||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||
docker volume or mounted from the host filesystem.
|
||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||
or in the current directory). If there isn't, copy
|
||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||
(passwords, secrets, ...).
|
||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||
you specified the DB URL, which defaults to the current directory). If
|
||||
there isn't, check that the user running the command (user with ID 10001
|
||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||
docker volume or mounted from the host filesystem.
|
||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||
or in the current directory). If there isn't, copy
|
||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||
(passwords, secrets, ...).
|
||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||
you specified the DB URL, which defaults to the current directory). If
|
||||
there isn't, check that the user running the command (user with ID 10001
|
||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
[package]
|
||||
name = "lldap_app"
|
||||
version = "0.3.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
description = "Frontend for LLDAP"
|
||||
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.1"
|
||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
base64 = "0.13"
|
||||
gloo-console = "0.2.3"
|
||||
gloo-file = "0.2.3"
|
||||
gloo-net = "*"
|
||||
graphql_client = "0.10"
|
||||
http = "0.2"
|
||||
jwt = "0.13"
|
||||
rand = "0.8"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
validator = "=0.14"
|
||||
validator_derive = "*"
|
||||
url-escape = "0.1.1"
|
||||
validator = "0.14"
|
||||
validator_derive = "0.14"
|
||||
wasm-bindgen = "0.2"
|
||||
yew = "0.18"
|
||||
yewtil = "*"
|
||||
yew-router = "0.15"
|
||||
yew_form = "0.1.8"
|
||||
yew_form_derive = "*"
|
||||
wasm-bindgen-futures = "*"
|
||||
yew = "0.19.3"
|
||||
yew-router = "0.16"
|
||||
|
||||
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
||||
indexmap = "=1.6.2"
|
||||
@@ -29,11 +37,16 @@ version = "0.3"
|
||||
features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"Event",
|
||||
"FileReader",
|
||||
"FormData",
|
||||
"HtmlDocument",
|
||||
"HtmlFormElement",
|
||||
"HtmlInputElement",
|
||||
"HtmlOptionElement",
|
||||
"HtmlOptionsCollection",
|
||||
"HtmlSelectElement",
|
||||
"SubmitEvent",
|
||||
"console",
|
||||
]
|
||||
|
||||
@@ -47,5 +60,25 @@ features = [
|
||||
path = "../auth"
|
||||
features = [ "opaque_client" ]
|
||||
|
||||
[dependencies.image]
|
||||
features = ["jpeg"]
|
||||
default-features = false
|
||||
version = "0.24"
|
||||
|
||||
[dependencies.yew_form]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[dependencies.yew_form_derive]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[lib]
|
||||
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`'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wasm-pack build --target web
|
||||
|
||||
ROLLUP_BIN=$(which rollup 2>/dev/null)
|
||||
if [ -f ../node_modules/rollup/dist/bin/rollup ]
|
||||
if ! which gzip > /dev/null 2>&1
|
||||
then
|
||||
ROLLUP_BIN=../node_modules/rollup/dist/bin/rollup
|
||||
elif [ -f node_modules/rollup/dist/bin/rollup ]
|
||||
then
|
||||
ROLLUP_BIN=node_modules/rollup/dist/bin/rollup
|
||||
fi
|
||||
|
||||
if [ -z "$ROLLUP_BIN" ]
|
||||
then
|
||||
>&2 echo '`rollup` not found. Try running `npm install rollup`'
|
||||
>&2 echo '`gzip` not found.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap
|
||||
wasm-pack build --target web --release
|
||||
|
||||
gzip -9 -k -f pkg/lldap_app_bg.wasm
|
||||
|
||||
@@ -4,17 +4,22 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<base href="/">
|
||||
<script src="static/main.js" type="module" defer></script>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
||||
rel="preload stylesheet"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||
crossorigin="anonymous"
|
||||
as="style" />
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
||||
crossorigin="anonymous"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
|
||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
|
||||
crossorigin="anonymous"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
||||
@@ -29,11 +34,33 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
||||
<link
|
||||
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">
|
||||
</head>
|
||||
|
||||
<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>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<script src="/static/main.js" type="module" defer></script>
|
||||
<link
|
||||
href="/static/bootstrap.min.css"
|
||||
href="/static/bootstrap-nightshade.min.css"
|
||||
rel="preload stylesheet"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||
as="style" />
|
||||
<script
|
||||
src="/static/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||
<script
|
||||
src="/static/darkmode.min.js"
|
||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/bootstrap-icons.css"
|
||||
@@ -28,10 +31,32 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<script>
|
||||
function inDarkMode(){
|
||||
return darkmode.inDarkMode;
|
||||
}
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<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>
|
||||
|
||||
</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!) {
|
||||
createGroup(name: $name) {
|
||||
mutation CreateGroup($group: CreateGroupInput!) {
|
||||
createGroupWithDetails(request: $group) {
|
||||
id
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,28 @@ query GetGroupDetails($id: Int!) {
|
||||
group(groupId: $id) {
|
||||
id
|
||||
displayName
|
||||
creationDate
|
||||
uuid
|
||||
users {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
attributes {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
schema {
|
||||
groupSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ query GetGroupList {
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
creationDate
|
||||
}
|
||||
}
|
||||
|
||||
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,13 +2,30 @@ query GetUserDetails($id: String!) {
|
||||
user(userId: $id) {
|
||||
id
|
||||
email
|
||||
avatar
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
creationDate
|
||||
uuid
|
||||
groups {
|
||||
id
|
||||
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 {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserListResponse(response) => {
|
||||
self.user_list = Some(response?.users);
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddMember => return self.submit_add_member(),
|
||||
Msg::SubmitAddMember => return self.submit_add_member(ctx),
|
||||
Msg::AddMemberResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
let user = self
|
||||
.selected_user
|
||||
.as_ref()
|
||||
.expect("Could not get selected user")
|
||||
.clone();
|
||||
// Remove the user from the dropdown.
|
||||
self.common.on_user_added_to_group.emit(user);
|
||||
ctx.props().on_user_added_to_group.emit(user);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_user.is_some();
|
||||
@@ -88,23 +90,25 @@ impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||
}
|
||||
|
||||
impl AddGroupMemberComponent {
|
||||
fn get_user_list(&mut self) {
|
||||
fn get_user_list(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<ListUserNames, _>(
|
||||
ctx,
|
||||
list_user_names::Variables { filters: None },
|
||||
Msg::UserListResponse,
|
||||
"Error trying to fetch user list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_member(&mut self) -> Result<bool> {
|
||||
fn submit_add_member(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
let user_id = match self.selected_user.clone() {
|
||||
None => return Ok(false),
|
||||
Some(user) => user.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
ctx,
|
||||
add_user_to_group::Variables {
|
||||
user: user_id,
|
||||
group: self.common.group_id,
|
||||
group: ctx.props().group_id,
|
||||
},
|
||||
Msg::AddMemberResponse,
|
||||
"Error trying to initiate adding the user to a group",
|
||||
@@ -112,8 +116,8 @@ impl AddGroupMemberComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
|
||||
fn get_selectable_user_list(&self, ctx: &Context<Self>, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = ctx.props().users.iter().collect::<HashSet<_>>();
|
||||
user_list
|
||||
.iter()
|
||||
.filter(|u| !user_groups.contains(u))
|
||||
@@ -126,41 +130,44 @@ impl Component for AddGroupMemberComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user_list: None,
|
||||
selected_user: None,
|
||||
};
|
||||
res.get_user_list();
|
||||
res.get_user_list(ctx);
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
if let Some(user_list) = &self.user_list {
|
||||
let to_add_user_list = self.get_selectable_user_list(user_list);
|
||||
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||
#[allow(unused_braces)]
|
||||
let make_select_option = |user: User| {
|
||||
let name = if user.display_name.is_empty() {
|
||||
user.id.clone()
|
||||
} else {
|
||||
user.display_name.clone()
|
||||
};
|
||||
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! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||
{
|
||||
to_add_user_list
|
||||
.into_iter()
|
||||
@@ -169,12 +176,13 @@ impl Component for AddGroupMemberComponent {
|
||||
}
|
||||
</Select>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
||||
{"Add"}
|
||||
class="btn btn-secondary"
|
||||
disabled={self.selected_user.is_none() || self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitAddMember)}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Add to group"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,16 +64,18 @@ pub struct Props {
|
||||
}
|
||||
|
||||
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupListResponse(response) => {
|
||||
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddGroup => return self.submit_add_group(),
|
||||
Msg::SubmitAddGroup => return self.submit_add_group(ctx),
|
||||
Msg::AddGroupResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
// Adding the user to the group succeeded, we're not in the process of adding a
|
||||
// group anymore.
|
||||
let group = self
|
||||
@@ -82,7 +84,7 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
.expect("Could not get selected group")
|
||||
.clone();
|
||||
// Remove the group from the dropdown.
|
||||
self.common.on_user_added_to_group.emit(group);
|
||||
ctx.props().on_user_added_to_group.emit(group);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_group.is_some();
|
||||
@@ -102,22 +104,24 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
}
|
||||
|
||||
impl AddUserToGroupComponent {
|
||||
fn get_group_list(&mut self) {
|
||||
fn get_group_list(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetGroupList, _>(
|
||||
ctx,
|
||||
get_group_list::Variables,
|
||||
Msg::GroupListResponse,
|
||||
"Error trying to fetch group list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_group(&mut self) -> Result<bool> {
|
||||
fn submit_add_group(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
let group_id = match &self.selected_group {
|
||||
None => return Ok(false),
|
||||
Some(group) => group.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
ctx,
|
||||
add_user_to_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
user: ctx.props().username.clone(),
|
||||
group: group_id,
|
||||
},
|
||||
Msg::AddGroupResponse,
|
||||
@@ -126,8 +130,8 @@ impl AddUserToGroupComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
|
||||
fn get_selectable_group_list(&self, props: &Props, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = props.groups.iter().collect::<HashSet<_>>();
|
||||
group_list
|
||||
.iter()
|
||||
.filter(|g| !user_groups.contains(g))
|
||||
@@ -139,41 +143,39 @@ impl AddUserToGroupComponent {
|
||||
impl Component for AddUserToGroupComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group_list: None,
|
||||
selected_group: None,
|
||||
};
|
||||
res.get_group_list();
|
||||
res.get_group_list(ctx);
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
if let Some(group_list) = &self.group_list {
|
||||
let to_add_group_list = self.get_selectable_group_list(group_list);
|
||||
let to_add_group_list = self.get_selectable_group_list(ctx.props(), group_list);
|
||||
#[allow(unused_braces)]
|
||||
let make_select_option = |group: Group| {
|
||||
html_nested! {
|
||||
<SelectOption value=group.id.to_string() text=group.display_name key=group.id />
|
||||
<SelectOption value={group.id.to_string()} text={group.display_name} key={group.id} />
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||
{
|
||||
to_add_group_list
|
||||
.into_iter()
|
||||
@@ -182,12 +184,13 @@ impl Component for AddUserToGroupComponent {
|
||||
}
|
||||
</Select>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<div class="col-sm-3">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
||||
{"Add"}
|
||||
class="btn btn-secondary"
|
||||
disabled={self.selected_group.is_none() || self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitAddGroup)}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Add to group"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,169 +1,201 @@
|
||||
use crate::{
|
||||
components::{
|
||||
banner::Banner,
|
||||
change_password::ChangePasswordForm,
|
||||
create_group::CreateGroupForm,
|
||||
create_group_attribute::CreateGroupAttributeForm,
|
||||
create_user::CreateUserForm,
|
||||
create_user_attribute::CreateUserAttributeForm,
|
||||
group_details::GroupDetails,
|
||||
group_schema_table::ListGroupSchema,
|
||||
group_table::GroupTable,
|
||||
login::LoginForm,
|
||||
logout::LogoutButton,
|
||||
reset_password_step1::ResetPasswordStep1Form,
|
||||
reset_password_step2::ResetPasswordStep2Form,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
router::{AppRoute, Link, Redirect},
|
||||
user_details::UserDetails,
|
||||
user_schema_table::ListUserSchema,
|
||||
user_table::UserTable,
|
||||
},
|
||||
infra::cookies::get_cookie,
|
||||
};
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
router::Router,
|
||||
service::RouteService,
|
||||
infra::{api::HostService, cookies::get_cookie},
|
||||
};
|
||||
|
||||
use gloo_console::error;
|
||||
use 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 {
|
||||
link: ComponentLink<Self>,
|
||||
user_info: Option<(String, bool)>,
|
||||
redirect_to: Option<AppRoute>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
password_reset_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Login((String, bool)),
|
||||
Logout,
|
||||
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||
}
|
||||
|
||||
impl Component for App {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut app = Self {
|
||||
link,
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let app = Self {
|
||||
user_info: get_cookie("user_id")
|
||||
.unwrap_or_else(|e| {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
None
|
||||
})
|
||||
.and_then(|u| {
|
||||
get_cookie("is_admin")
|
||||
.map(|so| so.map(|s| (u, s == "true")))
|
||||
.unwrap_or_else(|e| {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
None
|
||||
})
|
||||
}),
|
||||
redirect_to: Self::get_redirect_route(),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
redirect_to: Self::get_redirect_route(ctx),
|
||||
password_reset_enabled: None,
|
||||
};
|
||||
app.apply_initial_redirections();
|
||||
ctx.link().send_future(async move {
|
||||
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
|
||||
});
|
||||
app.apply_initial_redirections(ctx);
|
||||
app
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
let history = ctx.link().history().unwrap();
|
||||
match msg {
|
||||
Msg::Login((user_name, is_admin)) => {
|
||||
self.user_info = Some((user_name.clone(), is_admin));
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
self.redirect_to.take().unwrap_or_else(|| {
|
||||
if is_admin {
|
||||
AppRoute::ListUsers
|
||||
} else {
|
||||
AppRoute::UserDetails(user_name.clone())
|
||||
}
|
||||
}),
|
||||
)));
|
||||
history.push(self.redirect_to.take().unwrap_or_else(|| {
|
||||
if is_admin {
|
||||
AppRoute::ListUsers
|
||||
} else {
|
||||
AppRoute::UserDetails {
|
||||
user_id: user_name.clone(),
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
Msg::Logout => {
|
||||
self.user_info = None;
|
||||
self.redirect_to = None;
|
||||
history.push(AppRoute::Login);
|
||||
}
|
||||
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
|
||||
self.password_reset_enabled = Some(enabled);
|
||||
}
|
||||
Msg::PasswordResetProbeFinished(Err(err)) => {
|
||||
self.password_reset_enabled = Some(false);
|
||||
error!(&format!(
|
||||
"Could not probe for password reset support: {err:#}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.user_info.is_none() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let link = self.link.clone();
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link().clone();
|
||||
let is_admin = self.is_admin();
|
||||
let username = self.user_info.clone().map(|(username, _)| username);
|
||||
let password_reset_enabled = self.password_reset_enabled;
|
||||
html! {
|
||||
<div class="container shadow-sm py-3">
|
||||
{self.view_banner()}
|
||||
<div>
|
||||
<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="shadow-sm py-3" style="max-width: 1000px">
|
||||
<Router<AppRoute>
|
||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
||||
<main class="py-3" style="max-width: 1000px">
|
||||
<Switch<AppRoute>
|
||||
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{self.view_footer()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn get_redirect_route() -> Option<AppRoute> {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.is_empty()
|
||||
|| current_route == "/"
|
||||
|| current_route.contains("login")
|
||||
|| current_route.contains("reset-password")
|
||||
{
|
||||
None
|
||||
} else {
|
||||
use yew_router::Switch;
|
||||
AppRoute::from_route_part::<()>(current_route, None).0
|
||||
}
|
||||
// Get the page to land on after logging in, defaulting to the index.
|
||||
fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
|
||||
let route = ctx.link().history().unwrap().location().route::<AppRoute>();
|
||||
route.filter(|route| {
|
||||
!matches!(
|
||||
route,
|
||||
AppRoute::Index
|
||||
| AppRoute::Login
|
||||
| AppRoute::StartResetPassword
|
||||
| AppRoute::FinishResetPassword { token: _ }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_initial_redirections(&mut self) {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.contains("reset-password") {
|
||||
return;
|
||||
}
|
||||
match &self.user_info {
|
||||
None => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
fn apply_initial_redirections(&self, ctx: &Context<Self>) {
|
||||
let history = ctx.link().history().unwrap();
|
||||
let route = history.location().route::<AppRoute>();
|
||||
let redirection = match (route, &self.user_info, &self.redirect_to) {
|
||||
(
|
||||
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
|
||||
_,
|
||||
_,
|
||||
) => {
|
||||
if self.password_reset_enabled == Some(false) {
|
||||
Some(AppRoute::Login)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Some((user_name, is_admin)) => match &self.redirect_to {
|
||||
Some(url) => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
|
||||
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
|
||||
// User is logged in, a URL was given, don't redirect.
|
||||
(_, Some(_), Some(_)) => None,
|
||||
(_, Some((user_name, is_admin)), None) => {
|
||||
if *is_admin {
|
||||
Some(AppRoute::ListUsers)
|
||||
} else {
|
||||
Some(AppRoute::UserDetails {
|
||||
user_id: user_name.clone(),
|
||||
})
|
||||
}
|
||||
None => {
|
||||
if *is_admin {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
|
||||
} else {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(
|
||||
AppRoute::UserDetails(user_name.clone()),
|
||||
)));
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
if let Some(redirect_to) = redirection {
|
||||
history.push(redirect_to);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
|
||||
fn dispatch_route(
|
||||
switch: &AppRoute,
|
||||
link: &Scope<Self>,
|
||||
is_admin: bool,
|
||||
password_reset_enabled: Option<bool>,
|
||||
) -> Html {
|
||||
match switch {
|
||||
AppRoute::Login => html! {
|
||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
||||
<LoginForm on_logged_in={link.callback(Msg::Login)} password_reset_enabled={password_reset_enabled.unwrap_or(false)}/>
|
||||
},
|
||||
AppRoute::CreateUser => html! {
|
||||
<CreateUserForm/>
|
||||
@@ -171,115 +203,71 @@ impl App {
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<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>
|
||||
},
|
||||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::CreateUserAttribute => html! {
|
||||
<CreateUserAttributeForm/>
|
||||
},
|
||||
AppRoute::CreateGroupAttribute => html! {
|
||||
<CreateGroupAttributeForm/>
|
||||
},
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<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>
|
||||
},
|
||||
AppRoute::GroupDetails(group_id) => html! {
|
||||
<GroupDetails group_id=group_id />
|
||||
AppRoute::ListUserSchema => html! {
|
||||
<ListUserSchema />
|
||||
},
|
||||
AppRoute::UserDetails(username) => html! {
|
||||
<UserDetails username=username is_admin=is_admin />
|
||||
AppRoute::ListGroupSchema => html! {
|
||||
<ListGroupSchema />
|
||||
},
|
||||
AppRoute::ChangePassword(username) => html! {
|
||||
<ChangePasswordForm username=username is_admin=is_admin />
|
||||
AppRoute::GroupDetails { group_id } => html! {
|
||||
<GroupDetails group_id={*group_id} is_admin={is_admin} />
|
||||
},
|
||||
AppRoute::StartResetPassword => html! {
|
||||
<ResetPasswordStep1Form />
|
||||
AppRoute::UserDetails { user_id } => html! {
|
||||
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||
},
|
||||
AppRoute::FinishResetPassword(token) => html! {
|
||||
<ResetPasswordStep2Form token=token />
|
||||
AppRoute::ChangePassword { user_id } => html! {
|
||||
<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 {
|
||||
html! {
|
||||
<header class="p-3 mb-4 border-bottom shadow-sm">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 me-md-5 text-dark text-decoration-none">
|
||||
<h1>{"LLDAP"}</h1>
|
||||
</a>
|
||||
|
||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||
{if self.is_admin() { html! {
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 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>
|
||||
None => html! {},
|
||||
},
|
||||
AppRoute::FinishResetPassword { token } => match password_reset_enabled {
|
||||
Some(true) => html! { <ResetPasswordStep2Form token={token.clone()} /> },
|
||||
Some(false) => {
|
||||
html! { <Redirect to={AppRoute::Login}/> }
|
||||
}
|
||||
None => html! {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view_footer(&self) -> 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>
|
||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||
</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>
|
||||
</a>
|
||||
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
|
||||
@@ -290,7 +278,7 @@ impl App {
|
||||
</a>
|
||||
</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>
|
||||
</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::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
#[derive(PartialEq, Eq, Default)]
|
||||
enum OpaqueData {
|
||||
#[default]
|
||||
None,
|
||||
Login(opaque::client::login::ClientLogin),
|
||||
Registration(opaque::client::registration::ClientRegistration),
|
||||
}
|
||||
|
||||
impl Default for OpaqueData {
|
||||
fn default() -> Self {
|
||||
OpaqueData::None
|
||||
}
|
||||
}
|
||||
|
||||
impl OpaqueData {
|
||||
fn take(&mut self) -> Self {
|
||||
std::mem::take(self)
|
||||
@@ -36,7 +32,7 @@ impl OpaqueData {
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(custom(
|
||||
function = "empty_or_long",
|
||||
@@ -61,10 +57,9 @@ pub struct ChangePasswordForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
opaque_data: OpaqueData,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
pub struct Props {
|
||||
pub username: String,
|
||||
pub is_admin: bool,
|
||||
@@ -80,15 +75,20 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
use anyhow::Context;
|
||||
match msg {
|
||||
Msg::FormUpdate => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
if self.common.is_admin {
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
if ctx.props().is_admin {
|
||||
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||
} else {
|
||||
let old_password = self.form.model().old_password;
|
||||
if old_password.is_empty() {
|
||||
@@ -100,14 +100,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
.context("Could not initialize login")?;
|
||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username: self.common.username.clone(),
|
||||
username: ctx.props().username.clone().into(),
|
||||
login_start_request: login_start_request.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::login_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::login_start(req),
|
||||
Msg::AuthenticationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -119,34 +119,33 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
|e| {
|
||||
// Common error, we want to print a full error to the console but only a
|
||||
// simple one to the user.
|
||||
ConsoleService::error(&format!(
|
||||
"Invalid username or password: {}",
|
||||
e
|
||||
));
|
||||
error!(&format!("Invalid username or password: {}", e));
|
||||
anyhow!("Invalid username or password")
|
||||
},
|
||||
)?;
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
};
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||
}
|
||||
Msg::SubmitNewPassword => {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let new_password = self.form.model().password;
|
||||
let registration_start_request =
|
||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let registration_start_request = opaque::client::registration::start_registration(
|
||||
new_password.as_bytes(),
|
||||
&mut rng,
|
||||
)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.common.username.clone(),
|
||||
username: ctx.props().username.clone().into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_start(req),
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
@@ -166,22 +165,20 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
);
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
}?;
|
||||
};
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
AppRoute::UserDetails(self.common.username.clone()),
|
||||
)));
|
||||
ctx.link().history().unwrap().push(AppRoute::UserDetails {
|
||||
user_id: ctx.props().username.clone(),
|
||||
});
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
@@ -198,114 +195,76 @@ impl Component for ChangePasswordForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
ChangePasswordForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: OpaqueData::None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let is_admin = self.common.is_admin;
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let is_admin = ctx.props().is_admin;
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<form
|
||||
class="form">
|
||||
{if !is_admin { html! {
|
||||
<div class="form-group row">
|
||||
<label for="old_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Current password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="old_password"
|
||||
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"
|
||||
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"
|
||||
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 {
|
||||
<div class="mb-2 mt-2">
|
||||
<h5 class="fw-bold">
|
||||
{"Change password"}
|
||||
</h5>
|
||||
</div>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-danger mt-3 mb-3">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div>
|
||||
<NavButton
|
||||
classes="btn btn-primary"
|
||||
route=AppRoute::UserDetails(self.common.username.clone())>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</div>
|
||||
<form class="form">
|
||||
{if !is_admin { html! {
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Current password"
|
||||
field_name="old_password"
|
||||
input_type="password"
|
||||
autocomplete="current-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
}} 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::{
|
||||
components::router::AppRoute,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
components::{
|
||||
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 validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_group_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetGroupAttributesSchema;
|
||||
|
||||
use get_group_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute =
|
||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||
|
||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||
fn from(attr: &Attribute) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: false, // Need to be admin to edit it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -24,11 +63,12 @@ pub struct CreateGroup;
|
||||
|
||||
pub struct CreateGroupForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateGroupModel>,
|
||||
attributes_schema: Option<Vec<Attribute>>,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct CreateGroupModel {
|
||||
#[validate(length(min = 1, message = "Groupname is required"))]
|
||||
groupname: String,
|
||||
@@ -36,23 +76,50 @@ pub struct CreateGroupModel {
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
SubmitForm,
|
||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
ensure!(self.form.validate(), "Check the form for errors");
|
||||
|
||||
let all_values = read_all_form_attributes(
|
||||
self.attributes_schema.iter().flatten(),
|
||||
&self.form_ref,
|
||||
IsAdmin(true),
|
||||
EmailIsRequired(false),
|
||||
)?;
|
||||
let attributes = Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| create_group::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let model = self.form.model();
|
||||
let req = create_group::Variables {
|
||||
name: model.groupname,
|
||||
group: create_group::CreateGroupInput {
|
||||
displayName: model.groupname,
|
||||
attributes,
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateGroup, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateGroupResponse,
|
||||
"Error trying to create group",
|
||||
@@ -60,12 +127,16 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateGroupResponse(response) => {
|
||||
ConsoleService::log(&format!(
|
||||
log!(&format!(
|
||||
"Created group '{}'",
|
||||
&response?.create_group.display_name
|
||||
&response?.create_group_with_details.display_name
|
||||
));
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes_schema =
|
||||
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -80,58 +151,54 @@ impl Component for CreateGroupForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||
}
|
||||
attributes_schema: None,
|
||||
form_ref: NodeRef::default(),
|
||||
};
|
||||
component
|
||||
.common
|
||||
.call_graphql::<GetGroupAttributesSchema, _>(
|
||||
ctx,
|
||||
get_group_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch group schema",
|
||||
);
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<CreateGroupModel>;
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<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">
|
||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="groupname"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group name*:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="groupname"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="groupname"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("groupname")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<Field<CreateGroupModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Group name"
|
||||
field_name="groupname"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
{
|
||||
self.attributes_schema
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_readonly && a.name != "display_name")
|
||||
.map(get_custom_attribute_input)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
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::{
|
||||
components::router::AppRoute,
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
field::Field,
|
||||
submit::Submit,
|
||||
},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
api::HostService,
|
||||
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 lldap_auth::{opaque, registration};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetUserAttributesSchema;
|
||||
|
||||
use get_user_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||
|
||||
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||
|
||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||
fn from(attr: &Attribute) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -28,20 +64,15 @@ pub struct CreateUser;
|
||||
|
||||
pub struct CreateUserForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateUserModel>,
|
||||
attributes_schema: Option<Vec<Attribute>>,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct CreateUserModel {
|
||||
#[validate(length(min = 1, message = "Username is required"))]
|
||||
username: String,
|
||||
#[validate(email(message = "A valid email is required"))]
|
||||
email: String,
|
||||
#[validate(length(min = 1, message = "Display name is required"))]
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
#[validate(custom(
|
||||
function = "empty_or_long",
|
||||
message = "Password should be longer than 8 characters (or left empty)"
|
||||
@@ -61,6 +92,7 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
SubmitForm,
|
||||
CreateUserResponse(Result<create_user::ResponseData>),
|
||||
SuccessfulCreation,
|
||||
@@ -74,25 +106,54 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes_schema =
|
||||
Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitForm => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
ensure!(self.form.validate(), "Check the form for errors");
|
||||
|
||||
let all_values = read_all_form_attributes(
|
||||
self.attributes_schema.iter().flatten(),
|
||||
&self.form_ref,
|
||||
IsAdmin(true),
|
||||
EmailIsRequired(true),
|
||||
)?;
|
||||
let attributes = Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| create_user::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let model = self.form.model();
|
||||
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
|
||||
let req = create_user::Variables {
|
||||
user: create_user::CreateUserInput {
|
||||
id: model.username,
|
||||
email: model.email,
|
||||
displayName: to_option(model.display_name),
|
||||
firstName: to_option(model.first_name),
|
||||
lastName: to_option(model.last_name),
|
||||
email: None,
|
||||
displayName: None,
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
avatar: None,
|
||||
attributes,
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateUserResponse,
|
||||
"Error trying to create user",
|
||||
@@ -102,7 +163,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
Msg::CreateUserResponse(r) => {
|
||||
match r {
|
||||
Err(e) => return Err(e),
|
||||
Ok(r) => ConsoleService::log(&format!(
|
||||
Ok(r) => log!(&format!(
|
||||
"Created user '{}' at '{}'",
|
||||
&r.create_user.id, &r.create_user.creation_date
|
||||
)),
|
||||
@@ -116,18 +177,20 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
let opaque::client::registration::ClientRegistrationStartResult {
|
||||
state,
|
||||
message,
|
||||
} = opaque::client::registration::start_registration(&password, &mut rng)?;
|
||||
} = opaque::client::registration::start_registration(
|
||||
password.as_bytes(),
|
||||
&mut rng,
|
||||
)?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: user_id,
|
||||
username: user_id.into(),
|
||||
registration_start_request: message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(HostService::register_start, req, move |r| {
|
||||
.call_backend(ctx, HostService::register_start(req), move |r| {
|
||||
Msg::RegistrationStartResponse((state, r))
|
||||
})
|
||||
.context("Error trying to create user")?;
|
||||
});
|
||||
} else {
|
||||
self.update(Msg::SuccessfulCreation);
|
||||
self.update(ctx, Msg::SuccessfulCreation);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -143,22 +206,19 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
server_data: response.server_data,
|
||||
registration_upload: registration_upload.message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
.context("Error trying to register user")?;
|
||||
self.common.call_backend(
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
response?;
|
||||
self.handle_msg(Msg::SuccessfulCreation)
|
||||
self.handle_msg(ctx, Msg::SuccessfulCreation)
|
||||
}
|
||||
Msg::SuccessfulCreation => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListUsers)));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListUsers);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -173,177 +233,66 @@ impl Component for CreateUserForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||
}
|
||||
attributes_schema: None,
|
||||
form_ref: NodeRef::default(),
|
||||
};
|
||||
component.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||
ctx,
|
||||
get_user_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch user schema",
|
||||
);
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<CreateUserModel>;
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form shadow-sm py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a user"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="username"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"User name*:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="username"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("username")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email*:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
input_type="email"
|
||||
field_name="email"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="email"
|
||||
oninput=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 class="form py-3"
|
||||
ref={self.form_ref.clone()}>
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="User name"
|
||||
field_name="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
{
|
||||
self.attributes_schema
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_readonly)
|
||||
.map(get_custom_attribute_input)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Password"
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Confirm password"
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
@@ -355,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 {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteGroup => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroup => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteGroupQuery, _>(
|
||||
ctx,
|
||||
delete_group_query::Variables {
|
||||
group_id: self.common.group.id,
|
||||
group_id: ctx.props().group.id,
|
||||
},
|
||||
Msg::DeleteGroupResponse,
|
||||
"Error trying to delete group",
|
||||
@@ -58,12 +63,8 @@ impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
.on_group_deleted
|
||||
.emit(self.common.group.id);
|
||||
ctx.props().on_group_deleted.emit(ctx.props().group.id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -78,15 +79,15 @@ impl Component for DeleteGroup {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteGroupProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, first_render: bool) {
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
@@ -96,43 +97,42 @@ impl Component for DeleteGroup {
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteGroup)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteGroup {
|
||||
fn show_modal(&self) -> Html {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
|
||||
id={"deleteGroupModal".to_string() + &ctx.props().group.id.to_string()}
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteGroupModalLabel"
|
||||
aria-hidden="true"
|
||||
ref=self.node_ref.clone()>
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -141,25 +141,29 @@ impl DeleteGroup {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete group "}
|
||||
<b>{&self.common.group.display_name}</b>{"?"}
|
||||
<b>{&ctx.props().group.display_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteGroup)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</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 {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteUser => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUser => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteUserQuery, _>(
|
||||
ctx,
|
||||
delete_user_query::Variables {
|
||||
user: self.common.username.clone(),
|
||||
user: ctx.props().username.clone(),
|
||||
},
|
||||
Msg::DeleteUserResponse,
|
||||
"Error trying to delete user",
|
||||
@@ -55,12 +60,10 @@ impl CommonComponent<DeleteUser> for DeleteUser {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
ctx.props()
|
||||
.on_user_deleted
|
||||
.emit(self.common.username.clone());
|
||||
.emit(ctx.props().username.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -75,15 +78,15 @@ impl Component for DeleteUser {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteUserProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, first_render: bool) {
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
@@ -93,44 +96,43 @@ impl Component for DeleteUser {
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteUser)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteUser {
|
||||
fn show_modal(&self) -> Html {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteUserModal".to_string() + &self.common.username
|
||||
id={"deleteUserModal".to_string() + &ctx.props().username}
|
||||
tabindex="-1"
|
||||
//role="dialog"
|
||||
aria-labelledby="deleteUserModalLabel"
|
||||
aria-hidden="true"
|
||||
ref=self.node_ref.clone()>
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog" /*role="document"*/>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -139,25 +141,29 @@ impl DeleteUser {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete user "}
|
||||
<b>{&self.common.username}</b>{"?"}
|
||||
<b>{&ctx.props().username}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
{"Cancel"}
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteUser)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</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::{
|
||||
components::{
|
||||
add_group_member::{self, AddGroupMemberComponent},
|
||||
group_details_form::GroupDetailsForm,
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
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 graphql_client::GraphQLQuery;
|
||||
@@ -22,12 +27,28 @@ pub struct GetGroupDetails;
|
||||
pub type Group = get_group_details::GetGroupDetailsGroup;
|
||||
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
||||
pub type AddGroupMemberUser = add_group_member::User;
|
||||
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
|
||||
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
|
||||
pub type AttributeType = get_group_details::AttributeType;
|
||||
|
||||
convert_attribute_type!(AttributeType);
|
||||
|
||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||
fn from(attr: &AttributeSchema) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GroupDetails {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
group: Option<Group>,
|
||||
group_and_schema: Option<(Group, Vec<AttributeSchema>)>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -38,18 +59,21 @@ pub enum Msg {
|
||||
OnError(Error),
|
||||
OnUserAddedToGroup(AddGroupMemberUser),
|
||||
OnUserRemovedFromGroup((String, i64)),
|
||||
DisplayNameUpdated,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub group_id: i64,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl GroupDetails {
|
||||
fn get_group_details(&mut self) {
|
||||
fn get_group_details(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetGroupDetails, _>(
|
||||
ctx,
|
||||
get_group_details::Variables {
|
||||
id: self.common.group_id,
|
||||
id: ctx.props().group_id,
|
||||
},
|
||||
Msg::GroupDetailsResponse,
|
||||
"Error trying to fetch group details",
|
||||
@@ -68,34 +92,48 @@ impl GroupDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_list(&self, g: &Group) -> Html {
|
||||
fn view_details(&self, ctx: &Context<Self>, g: &Group, schema: Vec<AttributeSchema>) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<h3>{g.display_name.to_string()}</h3>
|
||||
<GroupDetailsForm
|
||||
group={g.clone()}
|
||||
group_attributes_schema={schema}
|
||||
is_admin={ctx.props().is_admin}
|
||||
on_display_name_updated={ctx.link().callback(|_| Msg::DisplayNameUpdated)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_list(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||
let link = ctx.link();
|
||||
let make_user_row = |user: &User| {
|
||||
let user_id = user.id.clone();
|
||||
let display_name = user.display_name.clone();
|
||||
html! {
|
||||
<tr>
|
||||
<td>
|
||||
<Link route=AppRoute::UserDetails(user_id.clone())>
|
||||
<Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
|
||||
{user_id.clone()}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{display_name}</td>
|
||||
<td>
|
||||
<RemoveUserFromGroupComponent
|
||||
username=user_id
|
||||
group_id=g.id
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={user_id}
|
||||
group_id={g.id}
|
||||
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<>
|
||||
<h3>{g.display_name.to_string()}</h3>
|
||||
<h5 class="fw-bold">{"Members"}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr key="headerRow">
|
||||
<th>{"User Id"}</th>
|
||||
@@ -107,7 +145,7 @@ impl GroupDetails {
|
||||
{if g.users.is_empty() {
|
||||
html! {
|
||||
<tr key="EmptyRow">
|
||||
<td>{"No members"}</td>
|
||||
<td>{"There are no users in this group."}</td>
|
||||
<td/>
|
||||
</tr>
|
||||
}
|
||||
@@ -121,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
|
||||
.users
|
||||
.iter()
|
||||
@@ -132,38 +171,47 @@ impl GroupDetails {
|
||||
.collect();
|
||||
html! {
|
||||
<AddGroupMemberComponent
|
||||
group_id=g.id
|
||||
users=users
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
group_id={g.id}
|
||||
users={users}
|
||||
on_error={link.callback(Msg::OnError)}
|
||||
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
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) => {
|
||||
self.group = None;
|
||||
self.group_and_schema = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
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,
|
||||
display_name: user.display_name,
|
||||
});
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||
self.group
|
||||
self.group_and_schema
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.0
|
||||
.users
|
||||
.retain(|u| u.id != user_id);
|
||||
}
|
||||
Msg::DisplayNameUpdated => self.get_group_details(ctx),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -177,32 +225,29 @@ impl Component for GroupDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
group: None,
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group_and_schema: None,
|
||||
};
|
||||
table.get_group_details();
|
||||
table.get_group_details(ctx);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
match (&self.group, &self.common.error) {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.group_and_schema, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some(u), error) => {
|
||||
(Some((group, schema)), error) => {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_user_list(u)}
|
||||
{self.view_add_user_button(u)}
|
||||
{self.view_details(ctx, group, schema.clone())}
|
||||
{self.view_user_list(ctx, group)}
|
||||
{self.view_add_user_button(ctx, group)}
|
||||
{self.view_messages(error)}
|
||||
</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>
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use yew::prelude::*;
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_group_list.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetGroupList;
|
||||
@@ -34,7 +34,7 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupTable> for GroupTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListGroupsResponse(groups) => {
|
||||
self.groups = Some(groups?.groups.into_iter().collect());
|
||||
@@ -58,12 +58,13 @@ impl Component for GroupTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = GroupTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
groups: None,
|
||||
};
|
||||
table.common.call_graphql::<GetGroupList, _>(
|
||||
ctx,
|
||||
get_group_list::Variables {},
|
||||
Msg::ListGroupsResponse,
|
||||
"Error trying to fetch groups",
|
||||
@@ -71,18 +72,14 @@ impl Component for GroupTable {
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_groups()}
|
||||
{self.view_groups(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
@@ -90,19 +87,20 @@ impl Component for GroupTable {
|
||||
}
|
||||
|
||||
impl GroupTable {
|
||||
fn view_groups(&self) -> Html {
|
||||
fn view_groups(&self, ctx: &Context<Self>) -> Html {
|
||||
let make_table = |groups: &Vec<Group>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Groups"}</th>
|
||||
<th>{"Group name"}</th>
|
||||
<th>{"Creation date"}</th>
|
||||
<th>{"Delete"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.iter().map(|u| self.view_group(u)).collect::<Vec<_>>()}
|
||||
{groups.iter().map(|u| self.view_group(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -114,19 +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! {
|
||||
<tr key=group.id>
|
||||
<tr key={group.id}>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||
{&group.display_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{&group.creation_date.naive_local().date()}
|
||||
</td>
|
||||
<td>
|
||||
<DeleteGroup
|
||||
group=group.clone()
|
||||
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
group={group.clone()}
|
||||
on_group_deleted={link.callback(Msg::OnGroupDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::{
|
||||
form::submit::Submit,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
@@ -19,7 +23,7 @@ pub struct LoginForm {
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 1, message = "Missing username"))]
|
||||
username: String,
|
||||
@@ -30,6 +34,7 @@ pub struct FormModel {
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct Props {
|
||||
pub on_logged_in: Callback<(String, bool)>,
|
||||
pub password_reset_enabled: bool,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -46,7 +51,12 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<LoginForm> for LoginForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
use anyhow::Context;
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@@ -59,13 +69,13 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
opaque::client::login::start_login(&password, &mut rng)
|
||||
.context("Could not initialize login")?;
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username,
|
||||
username: username.into(),
|
||||
login_start_request: message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(HostService::login_start, req, move |r| {
|
||||
.call_backend(ctx, HostService::login_start(req), move |r| {
|
||||
Msg::AuthenticationStartResponse((state, r))
|
||||
})?;
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
||||
@@ -76,9 +86,8 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
Err(e) => {
|
||||
// Common error, we want to print a full error to the console but only a
|
||||
// simple one to the user.
|
||||
ConsoleService::error(&format!("Invalid username or password: {}", e));
|
||||
error!(&format!("Invalid username or password: {}", e));
|
||||
self.common.error = Some(anyhow!("Invalid username or password"));
|
||||
self.common.cancel_task();
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(l) => l,
|
||||
@@ -88,24 +97,22 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
credential_finalization: login_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::login_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::login_finish(req),
|
||||
Msg::AuthenticationFinishResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::AuthenticationFinishResponse(user_info) => {
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
ctx.props()
|
||||
.on_logged_in
|
||||
.emit(user_info.context("Could not log in")?);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationRefreshResponse(user_info) => {
|
||||
self.refreshing = false;
|
||||
self.common.cancel_task();
|
||||
if let Ok(user_info) = user_info {
|
||||
self.common.on_logged_in.emit(user_info);
|
||||
ctx.props().on_logged_in.emit(user_info);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -121,32 +128,28 @@ impl Component for LoginForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut app = LoginForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
refreshing: true,
|
||||
};
|
||||
if let Err(e) =
|
||||
app.common
|
||||
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse)
|
||||
{
|
||||
ConsoleService::debug(&format!("Could not refresh auth: {}", e));
|
||||
app.refreshing = false;
|
||||
}
|
||||
app.common.call_backend(
|
||||
ctx,
|
||||
HostService::refresh(),
|
||||
Msg::AuthenticationRefreshResponse,
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
let password_reset_enabled = ctx.props().password_reset_enabled;
|
||||
let link = &ctx.link();
|
||||
if self.refreshing {
|
||||
html! {
|
||||
<div>
|
||||
@@ -155,61 +158,62 @@ impl Component for LoginForm {
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
</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) />
|
||||
<form class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=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! {} }
|
||||
}
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,20 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<LogoutButton> for LogoutButton {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::LogoutRequested => {
|
||||
self.common
|
||||
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
|
||||
.call_backend(ctx, HostService::logout(), Msg::LogoutCompleted);
|
||||
}
|
||||
Msg::LogoutCompleted(res) => {
|
||||
res?;
|
||||
delete_cookie("user_id")?;
|
||||
self.common.on_logged_out.emit(());
|
||||
ctx.props().on_logged_out.emit(());
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
@@ -45,25 +49,22 @@ impl Component for LogoutButton {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
LogoutButton {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
||||
onclick={link.callback(|_| Msg::LogoutRequested)}>
|
||||
{"Logout"}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
pub mod add_group_member;
|
||||
pub mod add_user_to_group;
|
||||
pub mod app;
|
||||
pub mod avatar;
|
||||
pub mod banner;
|
||||
pub mod change_password;
|
||||
pub mod create_group;
|
||||
pub mod create_group_attribute;
|
||||
pub mod create_user;
|
||||
pub mod create_user_attribute;
|
||||
pub mod delete_group;
|
||||
pub mod delete_group_attribute;
|
||||
pub mod delete_user;
|
||||
pub mod delete_user_attribute;
|
||||
pub mod form;
|
||||
pub mod group_details;
|
||||
pub mod group_details_form;
|
||||
pub mod group_schema_table;
|
||||
pub mod group_table;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
@@ -17,4 +26,5 @@ pub mod router;
|
||||
pub mod select;
|
||||
pub mod user_details;
|
||||
pub mod user_details_form;
|
||||
pub mod user_schema_table;
|
||||
pub mod user_table;
|
||||
|
||||
@@ -31,15 +31,18 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(ctx),
|
||||
Msg::RemoveGroupResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
ctx.props()
|
||||
.on_user_removed_from_group
|
||||
.emit((self.common.username.clone(), self.common.group_id));
|
||||
.emit((ctx.props().username.clone(), ctx.props().group_id));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -51,11 +54,12 @@ impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupCompon
|
||||
}
|
||||
|
||||
impl RemoveUserFromGroupComponent {
|
||||
fn submit_remove_group(&mut self) {
|
||||
fn submit_remove_group(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
||||
ctx,
|
||||
remove_user_from_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
group: self.common.group_id,
|
||||
user: ctx.props().username.clone(),
|
||||
group: ctx.props().group_id,
|
||||
},
|
||||
Msg::RemoveGroupResponse,
|
||||
"Error trying to initiate removing the user from a group",
|
||||
@@ -67,30 +71,28 @@ impl Component for RemoveUserFromGroupComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitRemoveGroup)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
@@ -18,7 +18,7 @@ pub struct ResetPasswordStep1Form {
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 1, message = "Missing username"))]
|
||||
username: String,
|
||||
@@ -31,7 +31,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||
}
|
||||
let FormModel { username } = self.form.model();
|
||||
self.common.call_backend(
|
||||
HostService::reset_password_step1,
|
||||
&username,
|
||||
ctx,
|
||||
HostService::reset_password_step1(username),
|
||||
Msg::PasswordResetResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::PasswordResetResponse(response) => {
|
||||
@@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
ResetPasswordStep1Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
just_succeeded: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
self.just_succeeded = false;
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
@@ -95,15 +96,19 @@ impl Component for ResetPasswordStep1Form {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
placeholder="Username or email"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
{ if self.just_succeeded {
|
||||
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 {
|
||||
html! {
|
||||
@@ -111,23 +116,24 @@ impl Component for ResetPasswordStep1Form {
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-check-circle me-2"/>
|
||||
{"Reset password"}
|
||||
</button>
|
||||
<NavButton
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::Login>
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::Login}>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-danger mb-2">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use lldap_auth::{
|
||||
opaque::client::registration as opaque_registration,
|
||||
password_reset::ServerPasswordResetResponse, registration,
|
||||
@@ -14,13 +17,10 @@ use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
|
||||
password: String,
|
||||
@@ -33,10 +33,9 @@ pub struct ResetPasswordStep2Form {
|
||||
form: Form<FormModel>,
|
||||
username: Option<String>,
|
||||
opaque_data: Option<opaque_registration::ClientRegistration>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
pub struct Props {
|
||||
pub token: String,
|
||||
}
|
||||
@@ -50,11 +49,15 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
use anyhow::Context;
|
||||
match msg {
|
||||
Msg::ValidateTokenResponse(response) => {
|
||||
self.username = Some(response?.user_id);
|
||||
self.common.cancel_task();
|
||||
Ok(true)
|
||||
}
|
||||
Msg::FormUpdate => Ok(true),
|
||||
@@ -65,18 +68,18 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let new_password = self.form.model().password;
|
||||
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")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.username.clone().unwrap(),
|
||||
username: self.username.as_ref().unwrap().into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = Some(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_start(req),
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
@@ -94,17 +97,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
|
||||
ctx.link().history().unwrap().push(AppRoute::Login);
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
@@ -121,35 +122,28 @@ impl Component for ResetPasswordStep2Form {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = ResetPasswordStep2Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
username: None,
|
||||
};
|
||||
let token = component.common.token.clone();
|
||||
component
|
||||
.common
|
||||
.call_backend(
|
||||
HostService::reset_password_step2,
|
||||
&token,
|
||||
Msg::ValidateTokenResponse,
|
||||
)
|
||||
.unwrap();
|
||||
let token = ctx.props().token.clone();
|
||||
component.common.call_backend(
|
||||
ctx,
|
||||
HostService::reset_password_step2(token),
|
||||
Msg::ValidateTokenResponse,
|
||||
);
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
match (&self.username, &self.common.error) {
|
||||
(None, None) => {
|
||||
return html! {
|
||||
@@ -158,68 +152,44 @@ impl Component for ResetPasswordStep2Form {
|
||||
}
|
||||
(None, Some(e)) => {
|
||||
return html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
<>
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::Login}>
|
||||
{"Back"}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
<h2>{"Reset your password"}</h2>
|
||||
<form
|
||||
class="form">
|
||||
<div class="form-group row">
|
||||
<label for="new_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"New password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Confirm password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mt-2">
|
||||
<button
|
||||
class="btn btn-primary col-sm-1 col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<form class="form">
|
||||
<Field<FormModel>
|
||||
label="New password"
|
||||
required=true
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Field<FormModel>
|
||||
label="Confirm password"
|
||||
required=true
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
use yew_router::{
|
||||
components::{RouterAnchor, RouterButton},
|
||||
Switch,
|
||||
};
|
||||
use yew_router::Routable;
|
||||
|
||||
#[derive(Switch, Debug, Clone)]
|
||||
#[derive(Routable, Debug, Clone, PartialEq)]
|
||||
pub enum AppRoute {
|
||||
#[to = "/login"]
|
||||
#[at("/login")]
|
||||
Login,
|
||||
#[to = "/reset-password/step1"]
|
||||
#[at("/reset-password/step1")]
|
||||
StartResetPassword,
|
||||
#[to = "/reset-password/step2/{token}"]
|
||||
FinishResetPassword(String),
|
||||
#[to = "/users/create"]
|
||||
#[at("/reset-password/step2/:token")]
|
||||
FinishResetPassword { token: String },
|
||||
#[at("/users/create")]
|
||||
CreateUser,
|
||||
#[to = "/users"]
|
||||
#[at("/users")]
|
||||
ListUsers,
|
||||
#[to = "/user/{user_id}/password"]
|
||||
ChangePassword(String),
|
||||
#[to = "/user/{user_id}"]
|
||||
UserDetails(String),
|
||||
#[to = "/groups/create"]
|
||||
#[at("/user/:user_id/password")]
|
||||
ChangePassword { user_id: String },
|
||||
#[at("/user/:user_id")]
|
||||
UserDetails { user_id: String },
|
||||
#[at("/groups/create")]
|
||||
CreateGroup,
|
||||
#[to = "/groups"]
|
||||
#[at("/groups")]
|
||||
ListGroups,
|
||||
#[to = "/group/{group_id}"]
|
||||
GroupDetails(i64),
|
||||
#[to = "/"]
|
||||
#[at("/group/:group_id")]
|
||||
GroupDetails { group_id: i64 },
|
||||
#[at("/user-attributes")]
|
||||
ListUserSchema,
|
||||
#[at("/user-attributes/create")]
|
||||
CreateUserAttribute,
|
||||
#[at("/group-attributes")]
|
||||
ListGroupSchema,
|
||||
#[at("/group-attributes/create")]
|
||||
CreateGroupAttribute,
|
||||
#[at("/")]
|
||||
Index,
|
||||
}
|
||||
|
||||
pub type Link = RouterAnchor<AppRoute>;
|
||||
|
||||
pub type NavButton = RouterButton<AppRoute>;
|
||||
pub type Link = yew_router::components::Link<AppRoute>;
|
||||
pub type Redirect = yew_router::components::Redirect<AppRoute>;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use yew::{html::ChangeData, prelude::*};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
pub struct Select {
|
||||
link: ComponentLink<Self>,
|
||||
props: SelectProps,
|
||||
node_ref: NodeRef,
|
||||
}
|
||||
|
||||
@@ -14,100 +11,70 @@ pub struct SelectProps {
|
||||
}
|
||||
|
||||
pub enum SelectMsg {
|
||||
OnSelectChange(ChangeData),
|
||||
OnSelectChange,
|
||||
}
|
||||
|
||||
impl Select {
|
||||
fn get_nth_child_props(&self, nth: i32) -> Option<SelectOptionProps> {
|
||||
fn get_nth_child_props(&self, ctx: &Context<Self>, nth: i32) -> Option<SelectOptionProps> {
|
||||
if nth == -1 {
|
||||
return None;
|
||||
}
|
||||
self.props
|
||||
ctx.props()
|
||||
.children
|
||||
.iter()
|
||||
.nth(nth as usize)
|
||||
.map(|child| child.props)
|
||||
.map(|child| (*child.props).clone())
|
||||
}
|
||||
|
||||
fn send_selection_update(&self) {
|
||||
fn send_selection_update(&self, ctx: &Context<Self>) {
|
||||
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
|
||||
self.props
|
||||
ctx.props()
|
||||
.on_selection_change
|
||||
.emit(self.get_nth_child_props(select_node.selected_index()))
|
||||
.emit(self.get_nth_child_props(ctx, select_node.selected_index()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Select {
|
||||
type Message = SelectMsg;
|
||||
type Properties = SelectProps;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
node_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _first_render: bool) {
|
||||
self.send_selection_update();
|
||||
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
|
||||
self.send_selection_update(ctx);
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
let SelectMsg::OnSelectChange(data) = msg;
|
||||
match data {
|
||||
ChangeData::Select(_) => self.send_selection_update(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
fn update(&mut self, ctx: &Context<Self>, _: Self::Message) -> bool {
|
||||
self.send_selection_update(ctx);
|
||||
false
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.children.neq_assign(props.children)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<select
|
||||
ref=self.node_ref.clone()
|
||||
disabled=self.props.children.is_empty()
|
||||
onchange=self.link.callback(SelectMsg::OnSelectChange)>
|
||||
{ self.props.children.clone() }
|
||||
<select class="form-select"
|
||||
ref={self.node_ref.clone()}
|
||||
disabled={ctx.props().children.is_empty()}
|
||||
onchange={ctx.link().callback(|_| SelectMsg::OnSelectChange)}>
|
||||
{ ctx.props().children.clone() }
|
||||
</select>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SelectOption {
|
||||
props: SelectOptionProps,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct SelectOptionProps {
|
||||
pub value: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Component for SelectOption {
|
||||
type Message = ();
|
||||
type Properties = SelectOptionProps;
|
||||
|
||||
fn create(props: Self::Properties, _: ComponentLink<Self>) -> Self {
|
||||
Self { props }
|
||||
}
|
||||
|
||||
fn update(&mut self, _: Self::Message) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<option value=self.props.value.clone()>
|
||||
{&self.props.text}
|
||||
</option>
|
||||
}
|
||||
#[function_component(SelectOption)]
|
||||
pub fn select_option(props: &SelectOptionProps) -> Html {
|
||||
html! {
|
||||
<option value={props.value.clone()}>
|
||||
{&props.text}
|
||||
</option>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ use crate::{
|
||||
components::{
|
||||
add_user_to_group::AddUserToGroupComponent,
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
router::{AppRoute, Link},
|
||||
user_details_form::UserDetailsForm,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::GraphQlAttributeSchema,
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
@@ -22,12 +26,34 @@ pub struct GetUserDetails;
|
||||
|
||||
pub type User = get_user_details::GetUserDetailsUser;
|
||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
||||
pub type AttributeType = get_user_details::AttributeType;
|
||||
|
||||
convert_attribute_type!(AttributeType);
|
||||
|
||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||
fn from(attr: &AttributeSchema) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UserDetails {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
user: 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.
|
||||
@@ -40,32 +66,30 @@ pub enum Msg {
|
||||
OnUserRemovedFromGroup((String, i64)),
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub username: String,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetails> for UserDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserDetailsResponse(response) => match response {
|
||||
Ok(user) => self.user = Some(user.user),
|
||||
Ok(user) => {
|
||||
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
|
||||
}
|
||||
Err(e) => {
|
||||
self.user = None;
|
||||
self.user_and_schema = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(group) => {
|
||||
self.user.as_mut().unwrap().groups.push(group);
|
||||
self.mut_groups().push(group);
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
||||
self.user
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.groups
|
||||
.retain(|g| g.id != group_id);
|
||||
self.mut_groups().retain(|g| g.id != group_id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -77,10 +101,11 @@ impl CommonComponent<UserDetails> for UserDetails {
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn get_user_details(&mut self) {
|
||||
fn get_user_details(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetUserDetails, _>(
|
||||
ctx,
|
||||
get_user_details::Variables {
|
||||
id: self.common.username.clone(),
|
||||
id: ctx.props().username.clone(),
|
||||
},
|
||||
Msg::UserDetailsResponse,
|
||||
"Error trying to fetch user details",
|
||||
@@ -99,24 +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 display_name = group.display_name.clone();
|
||||
html! {
|
||||
<tr key="groupRow_".to_string() + &display_name>
|
||||
{if self.common.is_admin { html! {
|
||||
<tr key={"groupRow_".to_string() + &display_name}>
|
||||
{if ctx.props().is_admin { html! {
|
||||
<>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||
{&group.display_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<RemoveUserFromGroupComponent
|
||||
username=u.id.clone()
|
||||
group_id=group.id
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={u.id.clone()}
|
||||
group_id={group.id}
|
||||
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</>
|
||||
} } else { html! {
|
||||
@@ -129,18 +155,18 @@ impl UserDetails {
|
||||
<>
|
||||
<h5 class="row m-3 fw-bold">{"Group memberships"}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr key="headerRow">
|
||||
<th>{"Group"}</th>
|
||||
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
{ if ctx.props().is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{if u.groups.is_empty() {
|
||||
html! {
|
||||
<tr key="EmptyRow">
|
||||
<td>{"Not member of any group"}</td>
|
||||
<td>{"This user is not a member of any groups."}</td>
|
||||
</tr>
|
||||
}
|
||||
} else {
|
||||
@@ -153,14 +179,15 @@ impl UserDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_add_group_button(&self, u: &User) -> Html {
|
||||
if self.common.is_admin {
|
||||
fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||
let link = &ctx.link();
|
||||
if ctx.props().is_admin {
|
||||
html! {
|
||||
<AddUserToGroupComponent
|
||||
username=u.id.clone()
|
||||
groups=u.groups.clone()
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
username={u.id.clone()}
|
||||
groups={u.groups.clone()}
|
||||
on_error={link.callback(Msg::OnError)}
|
||||
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
@@ -172,47 +199,50 @@ impl Component for UserDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
user: None,
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user_and_schema: None,
|
||||
};
|
||||
table.get_user_details();
|
||||
table.get_user_details(ctx);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
match (&self.user, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some(u), error) => {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.user_and_schema, &self.common.error) {
|
||||
(Some((u, schema)), error) => {
|
||||
html! {
|
||||
<>
|
||||
<h3>{u.id.to_string()}</h3>
|
||||
<UserDetailsForm
|
||||
user=u.clone()
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
<div class="row justify-content-center">
|
||||
<NavButton
|
||||
route=AppRoute::ChangePassword(u.id.clone())
|
||||
classes="btn btn-primary col-auto">
|
||||
{"Change password"}
|
||||
</NavButton>
|
||||
<div class="d-flex flex-row-reverse">
|
||||
<Link
|
||||
to={AppRoute::ChangePassword{user_id: u.id.clone()}}
|
||||
classes="btn btn-secondary">
|
||||
<i class="bi-key me-2"></i>
|
||||
{"Modify password"}
|
||||
</Link>
|
||||
</div>
|
||||
{self.view_group_memberships(u)}
|
||||
{self.view_add_group_button(u)}
|
||||
<div>
|
||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||
</div>
|
||||
<UserDetailsForm
|
||||
user={u.clone()}
|
||||
user_attributes_schema={schema.clone()}
|
||||
is_admin={ctx.props().is_admin}
|
||||
is_edited_user_admin={u.groups.iter().any(|g| g.display_name == "lldap_admin")}
|
||||
/>
|
||||
{self.view_group_memberships(ctx, u)}
|
||||
{self.view_add_group_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
use crate::{
|
||||
components::user_details::User,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
components::{
|
||||
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 validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
/// The fields of the form, with the editable details and the constraints.
|
||||
#[derive(Model, Validate, PartialEq, 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.
|
||||
#[derive(GraphQLQuery)]
|
||||
@@ -25,7 +23,7 @@ pub struct UserModel {
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/update_user.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Clone,PartialEq",
|
||||
variables_derives = "Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct UpdateUser;
|
||||
@@ -33,9 +31,10 @@ pub struct UpdateUser;
|
||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||
pub struct UserDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<UserModel>,
|
||||
/// True if we just successfully updated the user, to display a success message.
|
||||
just_updated: bool,
|
||||
user: User,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -47,20 +46,29 @@ pub enum Msg {
|
||||
UserUpdated(Result<update_user::ResponseData>),
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
/// The current user details.
|
||||
pub user: User,
|
||||
/// Callback to report errors (e.g. server error).
|
||||
pub on_error: Callback<Error>,
|
||||
pub user_attributes_schema: Vec<AttributeSchema>,
|
||||
pub is_admin: bool,
|
||||
pub is_edited_user_admin: bool,
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||
Msg::UserUpdated(Err(e)) => Err(e),
|
||||
Msg::UserUpdated(Result::Ok(_)) => {
|
||||
self.just_updated = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,205 +81,195 @@ impl Component for UserDetailsForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<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(),
|
||||
};
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: yew_form::Form::new(model),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
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;
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
)
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<UserModel>;
|
||||
let can_edit =
|
||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||
let display_field = |a: &AttributeSchema| {
|
||||
if can_edit(a) {
|
||||
get_custom_attribute_input(a, &self.user.attributes)
|
||||
} else {
|
||||
get_custom_attribute_static(a, &self.user.attributes)
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="userId"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"User ID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="userId" class="form-constrol-static">{&self.common.user.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<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 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
|
||||
class="form"
|
||||
ref={self.form_ref.clone()}>
|
||||
<StaticValue label="User ID" id="userId">
|
||||
<i>{&self.user.id}</i>
|
||||
</StaticValue>
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.user_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded && a.name != "user_id")
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.user_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| !a.is_hardcoded)
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
text="Save changes"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||
</form>
|
||||
<div hidden=!self.just_updated>
|
||||
<span>{"User successfully updated!"}</span>
|
||||
{
|
||||
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">{"User successfully updated!"}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserDetailsForm {
|
||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
||||
if !self.form.validate() {
|
||||
bail!("Invalid inputs");
|
||||
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}
|
||||
/>
|
||||
}
|
||||
let base_user = &self.common.user;
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||
value={values.first().cloned().unwrap_or_default()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_static(
|
||||
attribute_schema: &AttributeSchema,
|
||||
user_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = user_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
html! {
|
||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||
</StaticValue>
|
||||
}
|
||||
}
|
||||
|
||||
impl UserDetailsForm {
|
||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
// TODO: Handle unloaded files.
|
||||
// if let Some(JsFile {
|
||||
// file: Some(_),
|
||||
// contents: None,
|
||||
// }) = &self.avatar
|
||||
// {
|
||||
// bail!("Image file hasn't finished loading, try again");
|
||||
// }
|
||||
let mut all_values = read_all_form_attributes(
|
||||
ctx.props().user_attributes_schema.iter(),
|
||||
&self.form_ref,
|
||||
IsAdmin(ctx.props().is_admin),
|
||||
EmailIsRequired(!ctx.props().is_edited_user_admin),
|
||||
)?;
|
||||
let base_attributes = &self.user.attributes;
|
||||
all_values.retain(|a| {
|
||||
let base_val = base_attributes
|
||||
.iter()
|
||||
.find(|base_val| base_val.name == a.name);
|
||||
base_val
|
||||
.map(|v| v.value != a.values)
|
||||
.unwrap_or(!a.values.is_empty())
|
||||
});
|
||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||
};
|
||||
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
|
||||
if remove_attributes.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| update_user::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let mut user_input = update_user::UpdateUserInput {
|
||||
id: self.common.user.id.clone(),
|
||||
id: self.user.id.clone(),
|
||||
email: None,
|
||||
displayName: None,
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
avatar: None,
|
||||
removeAttributes: None,
|
||||
insertAttributes: None,
|
||||
};
|
||||
let default_user_input = user_input.clone();
|
||||
let model = self.form.model();
|
||||
let email = model.email;
|
||||
if base_user.email != email {
|
||||
user_input.email = Some(email);
|
||||
}
|
||||
if base_user.display_name != model.display_name {
|
||||
user_input.displayName = Some(model.display_name);
|
||||
}
|
||||
if base_user.first_name != model.first_name {
|
||||
user_input.firstName = Some(model.first_name);
|
||||
}
|
||||
if base_user.last_name != model.last_name {
|
||||
user_input.lastName = Some(model.last_name);
|
||||
}
|
||||
user_input.removeAttributes = remove_attributes;
|
||||
user_input.insertAttributes = insert_attributes;
|
||||
// Nothing changed.
|
||||
if user_input == default_user_input {
|
||||
return Ok(false);
|
||||
}
|
||||
let req = update_user::Variables { user: user_input };
|
||||
self.common.call_graphql::<UpdateUser, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::UserUpdated,
|
||||
"Error trying to update user",
|
||||
);
|
||||
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 = User {
|
||||
id: self.common.user.id.clone(),
|
||||
email: model.email,
|
||||
display_name: model.display_name,
|
||||
first_name: model.first_name,
|
||||
last_name: model.last_name,
|
||||
creation_date: self.common.user.creation_date,
|
||||
groups: self.common.user.groups.clone(),
|
||||
};
|
||||
self.just_updated = true;
|
||||
}
|
||||
};
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListUsersResponse(users) => {
|
||||
self.users = Some(users?.users.into_iter().collect());
|
||||
@@ -55,8 +55,9 @@ impl CommonComponent<UserTable> for UserTable {
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
||||
fn get_users(&mut self, ctx: &Context<Self>, req: Option<RequestFilter>) {
|
||||
self.common.call_graphql::<ListUsersQuery, _>(
|
||||
ctx,
|
||||
list_users_query::Variables { filters: req },
|
||||
Msg::ListUsersResponse,
|
||||
"Error trying to fetch users",
|
||||
@@ -68,27 +69,23 @@ impl Component for UserTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = UserTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
users: None,
|
||||
};
|
||||
table.get_users(None);
|
||||
table.get_users(ctx, None);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_users()}
|
||||
{self.view_users(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
@@ -96,11 +93,11 @@ impl Component for UserTable {
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn view_users(&self) -> Html {
|
||||
fn view_users(&self, ctx: &Context<Self>) -> Html {
|
||||
let make_table = |users: &Vec<User>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"User ID"}</th>
|
||||
@@ -113,7 +110,7 @@ impl UserTable {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.iter().map(|u| self.view_user(u)).collect::<Vec<_>>()}
|
||||
{users.iter().map(|u| self.view_user(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -125,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! {
|
||||
<tr key=user.id.clone()>
|
||||
<td><Link route=AppRoute::UserDetails(user.id.clone())>{&user.id}</Link></td>
|
||||
<tr key={user.id.clone()}>
|
||||
<td><Link to={AppRoute::UserDetails{user_id: user.id.clone()}}>{&user.id}</Link></td>
|
||||
<td>{&user.email}</td>
|
||||
<td>{&user.display_name}</td>
|
||||
<td>{&user.first_name}</td>
|
||||
<td>{&user.last_name}</td>
|
||||
<td>{&user.creation_date.date().naive_local()}</td>
|
||||
<td>{&user.creation_date.naive_local().date()}</td>
|
||||
<td>
|
||||
<DeleteUser
|
||||
username=user.id.clone()
|
||||
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={user.id.clone()}
|
||||
on_user_deleted={link.callback(Msg::OnUserDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,136 +1,95 @@
|
||||
use super::cookies::set_cookie;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gloo_net::http::{Method, RequestBuilder};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{login, registration, JWTClaims};
|
||||
|
||||
use yew::callback::Callback;
|
||||
use yew::format::Json;
|
||||
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use web_sys::RequestCredentials;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HostService {}
|
||||
|
||||
fn get_default_options() -> FetchOptions {
|
||||
FetchOptions {
|
||||
credentials: Some(Credentials::SameOrigin),
|
||||
..FetchOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||
use jwt::*;
|
||||
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
||||
Ok(token.claims().clone())
|
||||
}
|
||||
|
||||
fn create_handler<Resp, CallbackResult, F>(
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
handler: F,
|
||||
) -> Callback<Response<Result<Resp>>>
|
||||
where
|
||||
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
|
||||
CallbackResult: 'static,
|
||||
{
|
||||
Callback::once(move |response: Response<Result<Resp>>| {
|
||||
let (meta, maybe_data) = response.into_parts();
|
||||
let message = maybe_data
|
||||
.context("Could not reach server")
|
||||
.and_then(|data| handler(meta.status, data));
|
||||
callback.emit(message)
|
||||
})
|
||||
enum RequestType<Body: Serialize> {
|
||||
Get,
|
||||
Post(Body),
|
||||
}
|
||||
|
||||
struct RequestBody<T>(T);
|
||||
const GET_REQUEST: RequestType<()> = RequestType::Get;
|
||||
|
||||
impl<'a, R> From<&'a R> for RequestBody<Json<&'a R>>
|
||||
where
|
||||
R: serde::ser::Serialize,
|
||||
{
|
||||
fn from(request: &'a R) -> Self {
|
||||
Self(Json(request))
|
||||
fn base_url() -> String {
|
||||
yew_router::utils::base_url().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn call_server<Body: Serialize>(
|
||||
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> {
|
||||
fn from(request: yew::format::Nothing) -> Self {
|
||||
Self(request)
|
||||
}
|
||||
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||
url: &str,
|
||||
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,
|
||||
request: RB,
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
request: RequestType<Body>,
|
||||
error_message: &'static str,
|
||||
parse_response: F,
|
||||
) -> Result<FetchTask>
|
||||
where
|
||||
F: Fn(String) -> Result<CallbackResult> + 'static,
|
||||
CallbackResult: 'static,
|
||||
RB: Into<RequestBody<Req>>,
|
||||
Req: Into<yew::format::Text>,
|
||||
{
|
||||
let request = {
|
||||
// If the request type is empty (if the size is 0), it's a get.
|
||||
if std::mem::size_of::<RB>() == 0 {
|
||||
Request::get(url)
|
||||
} else {
|
||||
Request::post(url)
|
||||
}
|
||||
}
|
||||
.header("Content-Type", "application/json")
|
||||
.body(request.into().0)?;
|
||||
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
|
||||
if status.is_success() {
|
||||
parse_response(data)
|
||||
} else {
|
||||
Err(anyhow!("{}[{}]: {}", error_message, status, data))
|
||||
}
|
||||
});
|
||||
FetchService::fetch_with_options(request, get_default_options(), handler)
|
||||
) -> Result<()> {
|
||||
call_server(url, request, error_message).await.map(|_| ())
|
||||
}
|
||||
|
||||
fn call_server_json_with_error_message<CallbackResult, RB, Req>(
|
||||
url: &str,
|
||||
request: RB,
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
where
|
||||
CallbackResult: serde::de::DeserializeOwned + 'static,
|
||||
RB: Into<RequestBody<Req>>,
|
||||
Req: Into<yew::format::Text>,
|
||||
{
|
||||
call_server(url, request, callback, error_message, |data: String| {
|
||||
serde_json::from_str(&data).context("Could not parse response")
|
||||
})
|
||||
}
|
||||
|
||||
fn call_server_empty_response_with_error_message<RB, Req>(
|
||||
url: &str,
|
||||
request: RB,
|
||||
callback: Callback<Result<()>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
where
|
||||
RB: Into<RequestBody<Req>>,
|
||||
Req: Into<yew::format::Text>,
|
||||
{
|
||||
call_server(
|
||||
url,
|
||||
request,
|
||||
callback,
|
||||
error_message,
|
||||
|_data: String| Ok(()),
|
||||
)
|
||||
fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, bool)> {
|
||||
let jwt_claims = get_claims_from_jwt(response.token.as_str()).context("Could not parse JWT")?;
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error setting cookie")
|
||||
}
|
||||
|
||||
impl HostService {
|
||||
pub fn graphql_query<QueryType>(
|
||||
pub async fn graphql_query<QueryType>(
|
||||
variables: QueryType::Variables,
|
||||
callback: Callback<Result<QueryType::ResponseData>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
) -> Result<QueryType::ResponseData>
|
||||
where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
{
|
||||
@@ -147,143 +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);
|
||||
call_server(
|
||||
"/api/graphql",
|
||||
&request_body,
|
||||
callback,
|
||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||
&(base_url() + "/api/graphql"),
|
||||
RequestType::Post(request_body),
|
||||
error_message,
|
||||
parse_graphql_response,
|
||||
)
|
||||
.await
|
||||
.and_then(unwrap_graphql_response)
|
||||
}
|
||||
|
||||
pub fn login_start(
|
||||
pub async fn login_start(
|
||||
request: login::ClientLoginStartRequest,
|
||||
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/login/start",
|
||||
&request,
|
||||
callback,
|
||||
&(base_url() + "/auth/opaque/login/start"),
|
||||
RequestType::Post(request),
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn login_finish(
|
||||
request: login::ClientLoginFinishRequest,
|
||||
callback: Callback<Result<(String, bool)>>,
|
||||
) -> Result<FetchTask> {
|
||||
let set_cookies = |jwt_claims: JWTClaims| {
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
"/auth/opaque/login/finish",
|
||||
&request,
|
||||
callback,
|
||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
&(base_url() + "/auth/opaque/login/finish"),
|
||||
RequestType::Post(request),
|
||||
"Could not finish authentication",
|
||||
parse_token,
|
||||
)
|
||||
.await
|
||||
.and_then(set_cookies_from_jwt)
|
||||
}
|
||||
|
||||
pub fn register_start(
|
||||
pub async fn register_start(
|
||||
request: registration::ClientRegistrationStartRequest,
|
||||
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/register/start",
|
||||
&request,
|
||||
callback,
|
||||
&(base_url() + "/auth/opaque/register/start"),
|
||||
RequestType::Post(request),
|
||||
"Could not start registration: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn register_finish(
|
||||
pub async fn register_finish(
|
||||
request: registration::ClientRegistrationFinishRequest,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/opaque/register/finish",
|
||||
&request,
|
||||
callback,
|
||||
&(base_url() + "/auth/opaque/register/finish"),
|
||||
RequestType::Post(request),
|
||||
"Could not finish registration",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
|
||||
let set_cookies = |jwt_claims: JWTClaims| {
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
"/auth/refresh",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
pub async fn refresh() -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
&(base_url() + "/auth/refresh"),
|
||||
GET_REQUEST,
|
||||
"Could not start authentication: ",
|
||||
parse_token,
|
||||
)
|
||||
.await
|
||||
.and_then(set_cookies_from_jwt)
|
||||
}
|
||||
|
||||
// The `_request` parameter is to make it the same shape as the other functions.
|
||||
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
|
||||
pub async fn logout() -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/logout",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
&(base_url() + "/auth/logout"),
|
||||
GET_REQUEST,
|
||||
"Could not logout",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn reset_password_step1(
|
||||
username: &str,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&format!("/auth/reset/step1/{}", username),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
&format!(
|
||||
"{}/auth/reset/step1/{}",
|
||||
base_url(),
|
||||
url_escape::encode_query(&username)
|
||||
),
|
||||
RequestType::Post(""),
|
||||
"Could not initiate password reset",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn reset_password_step2(
|
||||
token: &str,
|
||||
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
|
||||
) -> Result<FetchTask> {
|
||||
pub async fn reset_password_step2(
|
||||
token: String,
|
||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("/auth/reset/step2/{}", token),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
||||
GET_REQUEST,
|
||||
"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,21 +21,28 @@
|
||||
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
||||
//! take care of error and task handling.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
marker::PhantomData,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::infra::api::HostService;
|
||||
use anyhow::{Error, Result};
|
||||
use gloo_console::error;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{fetch::FetchTask, ConsoleService},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
/// Trait required for common components.
|
||||
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||
/// Handle the incoming message. If an error is returned here, any running task will be
|
||||
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
||||
/// component will be refreshed.
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool>;
|
||||
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||
}
|
||||
@@ -43,41 +50,33 @@ pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||
/// Structure that contains the common parts needed by most components.
|
||||
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
||||
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
||||
link: ComponentLink<C>,
|
||||
pub props: <C as Component>::Properties,
|
||||
pub error: Option<Error>,
|
||||
task: Option<FetchTask>,
|
||||
is_task_running: Arc<Mutex<bool>>,
|
||||
_phantom: PhantomData<C>,
|
||||
}
|
||||
|
||||
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
pub fn create() -> Self {
|
||||
CommonComponentParts {
|
||||
error: None,
|
||||
is_task_running: Arc::new(Mutex::new(false)),
|
||||
_phantom: PhantomData::<C>,
|
||||
}
|
||||
}
|
||||
/// Whether there is a currently running task in the background.
|
||||
pub fn is_task_running(&self) -> bool {
|
||||
self.task.is_some()
|
||||
}
|
||||
|
||||
/// Cancel any background task.
|
||||
pub fn cancel_task(&mut self) {
|
||||
self.task = None;
|
||||
}
|
||||
|
||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
error: None,
|
||||
task: None,
|
||||
}
|
||||
*self.is_task_running.lock().unwrap()
|
||||
}
|
||||
|
||||
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
||||
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
||||
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
|
||||
pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
|
||||
com.mut_common().error = None;
|
||||
match com.handle_msg(msg) {
|
||||
match com.handle_msg(ctx, msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
com.mut_common().error = Some(e);
|
||||
com.mut_common().cancel_task();
|
||||
assert!(!*com.mut_common().is_task_running.lock().unwrap());
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
@@ -87,10 +86,11 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
/// Same as above, but the resulting error is instead passed to the reporting function.
|
||||
pub fn update_and_report_error(
|
||||
com: &mut C,
|
||||
ctx: &Context<C>,
|
||||
msg: <C as Component>::Message,
|
||||
report_fn: Callback<Error>,
|
||||
) -> ShouldRender {
|
||||
let should_render = Self::update(com, msg);
|
||||
) -> bool {
|
||||
let should_render = Self::update(com, ctx, msg);
|
||||
com.mut_common()
|
||||
.error
|
||||
.take()
|
||||
@@ -101,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
.unwrap_or(should_render)
|
||||
}
|
||||
|
||||
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
|
||||
/// properties have changed and return whether the component should update.
|
||||
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
|
||||
where
|
||||
<C as yew::Component>::Properties: std::cmp::PartialEq,
|
||||
{
|
||||
self.props.neq_assign(props)
|
||||
}
|
||||
|
||||
/// Create a callback from the link.
|
||||
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
|
||||
where
|
||||
M: Into<C::Message>,
|
||||
F: Fn(IN) -> M + 'static,
|
||||
{
|
||||
self.link.callback(function)
|
||||
}
|
||||
|
||||
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
||||
/// result. Returns whether _starting the call_ failed.
|
||||
pub fn call_backend<M, Req, Cb, Resp>(
|
||||
&mut self,
|
||||
method: M,
|
||||
req: Req,
|
||||
callback: Cb,
|
||||
) -> Result<()>
|
||||
/// result.
|
||||
pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
|
||||
where
|
||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
||||
Fut: Future<Output = Resp> + 'static,
|
||||
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = Some(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.
|
||||
@@ -140,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
/// `EnumCallback` should usually be left as `_`.
|
||||
pub fn call_graphql<QueryType, EnumCallback>(
|
||||
&mut self,
|
||||
ctx: &Context<C>,
|
||||
variables: QueryType::Variables,
|
||||
enum_callback: EnumCallback,
|
||||
error_message: &'static str,
|
||||
@@ -147,29 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = HostService::graphql_query::<QueryType>(
|
||||
variables,
|
||||
self.link.callback(enum_callback),
|
||||
error_message,
|
||||
)
|
||||
.map_err::<(), _>(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
self.error = Some(e);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
|
||||
type Target = <C as Component>::Properties;
|
||||
|
||||
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
|
||||
&self.props
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
|
||||
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
|
||||
&mut self.props
|
||||
self.call_backend(
|
||||
ctx,
|
||||
HostService::graphql_query::<QueryType>(variables, error_message),
|
||||
enum_callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) ->
|
||||
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
|
||||
})?;
|
||||
let cookie_string = format!(
|
||||
"{}={}; expires={}; sameSite=Strict; path=/",
|
||||
"{}={}; expires={}; sameSite=Strict; path={}/",
|
||||
cookie_name,
|
||||
value,
|
||||
expiration.to_rfc2822()
|
||||
expiration.to_rfc2822(),
|
||||
yew_router::utils::base_url().unwrap_or_default()
|
||||
);
|
||||
doc.set_cookie(&cookie_string)
|
||||
.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<()> {
|
||||
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 {
|
||||
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 common_component;
|
||||
pub mod cookies;
|
||||
pub mod form_utils;
|
||||
pub mod functional;
|
||||
pub mod graphql;
|
||||
pub mod modal;
|
||||
pub mod schema;
|
||||
pub mod tooltip;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
#![allow(clippy::empty_docs)]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "bootstrap")]
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen]
|
||||
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||
pub type Modal;
|
||||
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||
pub fn new(e: web_sys::Element) -> Modal;
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||
pub fn show(this: &Modal);
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||
pub fn hide(this: &Modal);
|
||||
}
|
||||
|
||||
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"]
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::nonstandard_macro_braces)]
|
||||
#![allow(clippy::uninlined_format_args)]
|
||||
#![allow(clippy::let_unit_value)]
|
||||
|
||||
pub mod components;
|
||||
pub mod infra;
|
||||
|
||||
@@ -8,7 +10,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run_app() -> Result<(), JsValue> {
|
||||
yew::start_app::<components::app::App>();
|
||||
yew::start_app::<components::app::AppContainer>();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js
|
||||
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -10,3 +10,23 @@ header h1 {
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
html.dark .bg-light {
|
||||
background-color: rgba(59,59,59,1) !important;
|
||||
}
|
||||
|
||||
html.dark a {
|
||||
color: #e1e1e1
|
||||
}
|
||||
|
||||
a {
|
||||
color: #212529
|
||||
}
|
||||
|
||||
html.dark .nav-link {
|
||||
color: #e1e1e1
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #212529
|
||||
}
|
||||
@@ -1,36 +1,52 @@
|
||||
[package]
|
||||
name = "lldap_auth"
|
||||
version = "0.3.0-alpha.1"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
description = "Authentication protocol for LLDAP"
|
||||
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]
|
||||
default = ["opaque_server", "opaque_client"]
|
||||
opaque_server = []
|
||||
opaque_client = []
|
||||
js = []
|
||||
sea_orm = ["dep:sea-orm"]
|
||||
|
||||
[dependencies]
|
||||
rust-argon2 = "0.8"
|
||||
curve25519-dalek = "3"
|
||||
digest = "0.9"
|
||||
generic-array = "*"
|
||||
generic-array = "0.14"
|
||||
rand = "0.8"
|
||||
serde = "*"
|
||||
sha2 = "0.9"
|
||||
thiserror = "*"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.6"
|
||||
version = "0.7"
|
||||
|
||||
[dependencies.chrono]
|
||||
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.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
features = ["js"]
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
||||
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.
|
||||
pub mod login {
|
||||
use super::*;
|
||||
use super::{types::UserId, *};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerData {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub server_login: opaque::server::login::ServerLogin,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientLoginStartRequest {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub login_start_request: opaque::server::login::CredentialRequest,
|
||||
}
|
||||
|
||||
@@ -39,14 +39,14 @@ pub mod login {
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientSimpleLoginRequest {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientSimpleLoginRequest {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ClientSimpleLoginRequest")
|
||||
.field("username", &self.username)
|
||||
.field("username", &self.username.as_str())
|
||||
.field("password", &"***********")
|
||||
.finish()
|
||||
}
|
||||
@@ -63,16 +63,16 @@ pub mod login {
|
||||
/// The messages for the 3-step OPAQUE registration process.
|
||||
/// It is used to reset a user's password.
|
||||
pub mod registration {
|
||||
use super::*;
|
||||
use super::{types::UserId, *};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerData {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientRegistrationStartRequest {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
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)]
|
||||
pub struct JWTClaims {
|
||||
pub exp: DateTime<Utc>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::types::UserId;
|
||||
use opaque_ke::ciphersuite::CipherSuite;
|
||||
use rand::{CryptoRng, RngCore};
|
||||
|
||||
@@ -77,10 +78,10 @@ pub mod client {
|
||||
pub use opaque_ke::ClientRegistrationFinishParameters;
|
||||
/// Initiate the registration negotiation.
|
||||
pub fn start_registration<R: RngCore + CryptoRng>(
|
||||
password: &str,
|
||||
password: &[u8],
|
||||
rng: &mut R,
|
||||
) -> AuthenticationResult<ClientRegistrationStartResult> {
|
||||
Ok(ClientRegistration::start(rng, password.as_bytes())?)
|
||||
Ok(ClientRegistration::start(rng, password)?)
|
||||
}
|
||||
|
||||
/// Finalize the registration negotiation.
|
||||
@@ -145,12 +146,12 @@ pub mod server {
|
||||
pub fn start_registration(
|
||||
server_setup: &ServerSetup,
|
||||
registration_request: RegistrationRequest,
|
||||
username: &str,
|
||||
username: &UserId,
|
||||
) -> AuthenticationResult<ServerRegistrationStartResult> {
|
||||
Ok(ServerRegistration::start(
|
||||
server_setup,
|
||||
registration_request,
|
||||
username.as_bytes(),
|
||||
username.as_str().as_bytes(),
|
||||
)?)
|
||||
}
|
||||
|
||||
@@ -178,14 +179,14 @@ pub mod server {
|
||||
server_setup: &ServerSetup,
|
||||
password_file: Option<ServerRegistration>,
|
||||
credential_request: CredentialRequest,
|
||||
username: &str,
|
||||
username: &UserId,
|
||||
) -> AuthenticationResult<ServerLoginStartResult> {
|
||||
Ok(ServerLogin::start(
|
||||
rng,
|
||||
server_setup,
|
||||
password_file,
|
||||
credential_request,
|
||||
username.as_bytes(),
|
||||
username.as_str().as_bytes(),
|
||||
ServerLoginStartParameters::default(),
|
||||
)?)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user