Compare commits
527 Commits
v0.2.0
...
user-attri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b817980a9 | ||
|
|
66097f1880 | ||
|
|
adf3577f0e | ||
|
|
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 | ||
|
|
6701027002 | ||
|
|
fab884711f | ||
|
|
1a37e1ee04 | ||
|
|
786f571e86 | ||
|
|
33cd850e65 | ||
|
|
8c3a168c7f | ||
|
|
722fc2de57 | ||
|
|
c6ffaa2abf | ||
|
|
c4a63610c0 | ||
|
|
5bf533272e | ||
|
|
22fcc5303f | ||
|
|
8101ddc85f | ||
|
|
49f4e48aae | ||
|
|
4092b2e5b1 | ||
|
|
b387ceb1c4 | ||
|
|
85d59e79ca | ||
|
|
c5017bbd42 | ||
|
|
c72c1fdf2c | ||
|
|
cbde363fde | ||
|
|
ea82b1a644 | ||
|
|
429952c46f | ||
|
|
0dad470602 | ||
|
|
2f1bf87102 | ||
|
|
1a03346a38 | ||
|
|
23a4763914 | ||
|
|
82f6292927 | ||
|
|
e39e141d6c | ||
|
|
a512b1844a | ||
|
|
5e2eea0d97 | ||
|
|
bafb1dc5cc | ||
|
|
45bbe23b3b | ||
|
|
85ee097a3b | ||
|
|
04afc9d8d9 | ||
|
|
b03a38f267 | ||
|
|
8f446bd932 | ||
|
|
1ae7987b88 | ||
|
|
936a6d696a | ||
|
|
fc7ec97051 | ||
|
|
a67128338d | ||
|
|
e757638506 | ||
|
|
a673a6aa45 | ||
|
|
9b91362730 | ||
|
|
733d363e25 | ||
|
|
da186fab38 | ||
|
|
1f632a8069 | ||
|
|
ff698df280 | ||
|
|
1efab58d0c | ||
|
|
a0b0b455ed | ||
|
|
1d8582f937 | ||
|
|
7e62cc6eda | ||
|
|
55bcced476 | ||
|
|
b7957f598b | ||
|
|
5150d8341f | ||
|
|
e5c80b9f17 | ||
|
|
875c59758b | ||
|
|
b54fe9128d | ||
|
|
ebffc1c086 | ||
|
|
5c1db3cf4a | ||
|
|
e173f34edb | ||
|
|
05c60979d7 | ||
|
|
d6c2805847 | ||
|
|
89ae7c200c | ||
|
|
f689458aa2 | ||
|
|
6b6f11db1b | ||
|
|
f1b86a16ee | ||
|
|
4f89b73fe5 | ||
|
|
c7d68af691 | ||
|
|
4537d1ae2b | ||
|
|
90611aefef | ||
|
|
bd90a3a426 | ||
|
|
e1e1d6cd20 | ||
|
|
16a544b5a0 | ||
|
|
73ac5a65d4 | ||
|
|
5420dcf2b8 | ||
|
|
cb84f7f387 | ||
|
|
c7f45b12ac | ||
|
|
f52197e76f | ||
|
|
3ac38bb96f | ||
|
|
2197fe77a5 | ||
|
|
8d7881171b | ||
|
|
f2570cdd3c | ||
|
|
be452f4649 | ||
|
|
3a6c5fdc65 | ||
|
|
0ccedc6717 | ||
|
|
b6dd1ed512 | ||
|
|
a8e5549b3f | ||
|
|
ae9b3678df | ||
|
|
2221686dc6 | ||
|
|
203bc9a8a2 | ||
|
|
ca19e61f50 | ||
|
|
26cedcb621 | ||
|
|
6228c0f87c | ||
|
|
82df8d4ca1 | ||
|
|
c850fa4273 | ||
|
|
a1fe703bf0 | ||
|
|
d20bd196bc | ||
|
|
747e37592d | ||
|
|
f6c43b691a | ||
|
|
8e8614fe2e | ||
|
|
204232659d | ||
|
|
6c9086cc78 | ||
|
|
110b7c7d5b | ||
|
|
ef0a0ffced | ||
|
|
31cf9b8e2c | ||
|
|
aa83f6cab6 | ||
|
|
b38023c48e | ||
|
|
496fbf72ea | ||
|
|
86c052f98b | ||
|
|
610ada972a | ||
|
|
b664524366 | ||
|
|
182449da03 | ||
|
|
82770a5ff0 | ||
|
|
e11a8460ff | ||
|
|
c761f08995 | ||
|
|
c564de2c92 | ||
|
|
7731b8e593 | ||
|
|
4c05058eb2 | ||
|
|
45c50923b7 | ||
|
|
f730e6a580 | ||
|
|
06a12f5351 | ||
|
|
bf20c448dc | ||
|
|
9f138ec4ac | ||
|
|
ddeb4c3ce3 | ||
|
|
9d623e59c1 | ||
|
|
e44625bc6a | ||
|
|
68013c8919 | ||
|
|
842afac7dd | ||
|
|
2bbfacf755 | ||
|
|
f152a78cb6 |
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,12 +2,10 @@
|
|||||||
.git/*
|
.git/*
|
||||||
.github/*
|
.github/*
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
# Don't track cargo generated files
|
# Don't track cargo generated files
|
||||||
target/*
|
target/*
|
||||||
server/target/*
|
|
||||||
app/target/*
|
|
||||||
auth/target/*
|
|
||||||
|
|
||||||
# Don't track the generated JS
|
# Don't track the generated JS
|
||||||
app/pkg/*
|
app/pkg/*
|
||||||
@@ -16,10 +14,40 @@ app/pkg/*
|
|||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
# Don't track docs
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
CHANGELOG.md
|
||||||
|
README.md
|
||||||
|
docs/*
|
||||||
|
example_configs/*
|
||||||
|
|
||||||
|
# Output of `npm install rollup`
|
||||||
|
node_modules/*
|
||||||
|
package-lock.json
|
||||||
|
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
|
# Various config files that shouldn't be tracked
|
||||||
|
.env
|
||||||
lldap_config.toml
|
lldap_config.toml
|
||||||
server_key
|
server_key
|
||||||
users.db*
|
|
||||||
screenshot.png
|
screenshot.png
|
||||||
recipe.json
|
recipe.json
|
||||||
*.md
|
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.
|
||||||
23
.github/codecov.yml
vendored
Normal file
23
.github/codecov.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
codecov:
|
||||||
|
require_ci_to_pass: yes
|
||||||
|
comment:
|
||||||
|
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"
|
||||||
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Set update schedule for GitHub Actions
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
# Check for updates to GitHub Actions every weekday
|
||||||
|
interval: "daily"
|
||||||
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
|
||||||
84
.github/workflows/Dockerfile.ci.alpine-base
vendored
Normal file
84
.github/workflows/Dockerfile.ci.alpine-base
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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.16
|
||||||
|
WORKDIR /app
|
||||||
|
ENV UID=1000
|
||||||
|
ENV GID=1000
|
||||||
|
ENV USER=lldap
|
||||||
|
RUN apk add --no-cache tini ca-certificates bash tzdata && \
|
||||||
|
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
|
||||||
|
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
|
||||||
79
.github/workflows/Dockerfile.ci.debian-base
vendored
Normal file
79
.github/workflows/Dockerfile.ci.debian-base
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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 && \
|
||||||
|
apt clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
|
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||||
|
mkdir -p /data && chown $USER:$USER /data
|
||||||
|
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||||
|
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
VOLUME ["/data"]
|
||||||
|
WORKDIR /app
|
||||||
|
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||||
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
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
|
||||||
40
.github/workflows/Dockerfile.dev
vendored
Normal file
40
.github/workflows/Dockerfile.dev
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Keep tracking base image
|
||||||
|
FROM rust:1.74-slim-bookworm
|
||||||
|
|
||||||
|
# Set needed env path
|
||||||
|
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
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:latest #
|
||||||
|
#######################################################################################
|
||||||
|
# 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:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
- 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:latest
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||||
|
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
- 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.1.1
|
||||||
|
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.1.1
|
||||||
|
|
||||||
|
- 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@v5
|
||||||
|
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@v5
|
||||||
|
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@v5
|
||||||
|
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 }}
|
||||||
63
.github/workflows/docker.yml
vendored
63
.github/workflows/docker.yml
vendored
@@ -1,63 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
-
|
|
||||||
name: Checkout
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
-
|
|
||||||
name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v3
|
|
||||||
with:
|
|
||||||
# list of Docker images to use as base name for tags
|
|
||||||
images: |
|
|
||||||
nitnelave/lldap
|
|
||||||
# generate Docker tags based on the following events/attributes
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=sha
|
|
||||||
-
|
|
||||||
name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
-
|
|
||||||
name: Login to DockerHub
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
-
|
|
||||||
name: Build and push
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
platforms: linux/amd64
|
|
||||||
tags: nitnelave/lldap:latest
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
-
|
|
||||||
name: Update repo description
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: peter-evans/dockerhub-description@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
|
||||||
repository: nitnelave/lldap
|
|
||||||
20
.github/workflows/release-bot.yml
vendored
Normal file
20
.github/workflows/release-bot.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: Release Bot
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: nflaig/release-comment-on-pr@master
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_BOT_TOKEN }}
|
||||||
|
message: |
|
||||||
|
Thank you everyone for the contribution!
|
||||||
|
This feature is now available in the latest release, [${releaseTag}](${releaseUrl}).
|
||||||
|
You can support LLDAP by starring our repo, contributing some configuration examples and becoming a sponsor.
|
||||||
74
.github/workflows/rust.yml
vendored
74
.github/workflows/rust.yml
vendored
@@ -10,14 +10,31 @@ env:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
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: 'true'
|
||||||
|
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".dockerignore", ".gitignore", "lldap_config.docker_template.toml", "Dockerfile"]'
|
||||||
|
do_not_skip: '["workflow_dispatch", "schedule"]'
|
||||||
|
cancel_others: true
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: cargo test
|
name: cargo test
|
||||||
|
needs: pre_job
|
||||||
|
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.1.1
|
||||||
- uses: Swatinem/rust-cache@v1
|
- uses: Swatinem/rust-cache@v2
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose --workspace
|
run: cargo build --verbose --workspace
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
@@ -30,20 +47,14 @@ jobs:
|
|||||||
|
|
||||||
clippy:
|
clippy:
|
||||||
name: cargo clippy
|
name: cargo clippy
|
||||||
|
needs: pre_job
|
||||||
|
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
- name: Install nightly toolchain
|
- uses: Swatinem/rust-cache@v2
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
profile: minimal
|
|
||||||
toolchain: nightly
|
|
||||||
override: true
|
|
||||||
components: rustfmt, clippy
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v1
|
|
||||||
|
|
||||||
- name: Run cargo clippy
|
- name: Run cargo clippy
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@v1
|
||||||
@@ -53,20 +64,14 @@ jobs:
|
|||||||
|
|
||||||
format:
|
format:
|
||||||
name: cargo fmt
|
name: cargo fmt
|
||||||
|
needs: pre_job
|
||||||
|
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
- name: Install nightly toolchain
|
- uses: Swatinem/rust-cache@v2
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
profile: minimal
|
|
||||||
toolchain: nightly
|
|
||||||
override: true
|
|
||||||
components: rustfmt, clippy
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v1
|
|
||||||
|
|
||||||
- name: Run cargo fmt
|
- name: Run cargo fmt
|
||||||
uses: actions-rs/cargo@v1
|
uses: actions-rs/cargo@v1
|
||||||
@@ -76,27 +81,36 @@ jobs:
|
|||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
name: Code coverage
|
name: Code coverage
|
||||||
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout sources
|
- name: Checkout sources
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4.1.1
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: rustup toolchain install nightly --component llvm-tools-preview
|
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
- name: Install cargo-llvm-cov
|
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||||
run: curl -LsSf https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz | tar xzf - -C ~/.cargo/bin
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v1
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
- name: clean
|
|
||||||
run: cargo llvm-cov clean --workspace
|
|
||||||
- name: Generate code coverage for unit test
|
- name: Generate code coverage for unit test
|
||||||
run: cargo llvm-cov --workspace --no-report
|
run: cargo llvm-cov --workspace --no-report
|
||||||
- name: Aggregate reports
|
- name: Aggregate reports
|
||||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v1
|
uses: codecov/codecov-action@v3
|
||||||
|
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
|
||||||
with:
|
with:
|
||||||
files: lcov.info
|
files: lcov.info
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
|
- name: Upload coverage to Codecov (main)
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
with:
|
||||||
|
files: lcov.info
|
||||||
|
fail_ci_if_error: true
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,10 +1,7 @@
|
|||||||
# Generated by Cargo
|
# Generated by Cargo
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
/target
|
/target
|
||||||
/serve/target/
|
|
||||||
/app/target
|
|
||||||
/app/pkg
|
/app/pkg
|
||||||
/auth/target
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# These are backup files generated by rustfmt
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
@@ -22,6 +19,13 @@ package.json
|
|||||||
# Server private key
|
# Server private key
|
||||||
server_key
|
server_key
|
||||||
|
|
||||||
|
# Pre-build binaries
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
|
.vscode
|
||||||
.env
|
.env
|
||||||
recipe.json
|
recipe.json
|
||||||
|
lldap_config.toml
|
||||||
|
cert.pem
|
||||||
|
key.pem
|
||||||
|
|||||||
241
CHANGELOG.md
Normal file
241
CHANGELOG.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.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
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
As part of the update, the database will do a one-time automatic migration to
|
||||||
|
add UUIDs and group creation times.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added support and documentation for many services:
|
||||||
|
- Apache Guacamole
|
||||||
|
- Bookstack
|
||||||
|
- Calibre
|
||||||
|
- Dolibarr
|
||||||
|
- Emby
|
||||||
|
- Gitea
|
||||||
|
- Grafana
|
||||||
|
- Jellyfin
|
||||||
|
- Matrix Synapse
|
||||||
|
- NextCloud
|
||||||
|
- Organizr
|
||||||
|
- Portainer
|
||||||
|
- Seafile
|
||||||
|
- Syncthing
|
||||||
|
- WG Portal
|
||||||
|
- New migration tool from OpenLDAP.
|
||||||
|
- New docker images for alternate architectures (arm64, arm/v7).
|
||||||
|
- Added support for LDAPS.
|
||||||
|
- New readonly group.
|
||||||
|
- Added UUID attribute for users and groups.
|
||||||
|
- Frontend now uses the refresh tokens to reduce the number of logins needed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Much improved logging format.
|
||||||
|
- Simplified API login.
|
||||||
|
- Allowed non-admins to run search queries on the content they can see.
|
||||||
|
- "cn" attribute now returns the Full Name, not Username.
|
||||||
|
- Unknown attributes now warn instead of erroring.
|
||||||
|
- Introduced a list of attributes to silence those warnings.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
- Deprecated "cn" as LDAP username, "uid" is the correct attribute.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Usernames, objectclass and attribute names are now case insensitive.
|
||||||
|
- Handle "1.1" and other wildcard LDAP attributes.
|
||||||
|
- Handle "memberOf" attribute.
|
||||||
|
- Handle fully-specified scope.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Prevent SQL injections due to interaction between two libraries.
|
||||||
|
|
||||||
|
## [0.2.0] - 2021-11-27
|
||||||
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.
|
||||||
3919
Cargo.lock
generated
3919
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
26
Cargo.toml
@@ -2,14 +2,24 @@
|
|||||||
members = [
|
members = [
|
||||||
"server",
|
"server",
|
||||||
"auth",
|
"auth",
|
||||||
"app"
|
"app",
|
||||||
|
"migration-tool",
|
||||||
|
"set-password",
|
||||||
]
|
]
|
||||||
|
|
||||||
# TODO: remove when there's a new release.
|
default-members = ["server"]
|
||||||
[patch.crates-io.yew_form]
|
|
||||||
git = 'https://github.com/sassman/yew_form/'
|
|
||||||
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
|
|
||||||
|
|
||||||
[patch.crates-io.yew_form_derive]
|
resolver = "2"
|
||||||
git = 'https://github.com/sassman/yew_form/'
|
|
||||||
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
|
||||||
|
[profile.release.package.lldap_app]
|
||||||
|
opt-level = 's'
|
||||||
|
|
||||||
|
[patch.crates-io.opaque-ke]
|
||||||
|
git = 'https://github.com/nitnelave/opaque-ke/'
|
||||||
|
branch = 'zeroize_1.5'
|
||||||
|
|
||||||
|
[patch.crates-io.lber]
|
||||||
|
git = 'https://github.com/inejge/ldap3/'
|
||||||
|
|||||||
53
Dockerfile
53
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Build image
|
# Build image
|
||||||
FROM rust:alpine3.14 AS chef
|
FROM rust:alpine3.16 AS chef
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
# Add user
|
# Add user
|
||||||
@@ -11,7 +11,7 @@ RUN set -x \
|
|||||||
--uid 10001 \
|
--uid 10001 \
|
||||||
app \
|
app \
|
||||||
# Install required packages
|
# Install required packages
|
||||||
&& apk add npm openssl-dev musl-dev make perl curl
|
&& apk add openssl-dev musl-dev make perl curl gzip
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -19,7 +19,6 @@ WORKDIR /app
|
|||||||
RUN set -x \
|
RUN set -x \
|
||||||
# Install build tools
|
# Install build tools
|
||||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||||
&& npm install rollup \
|
|
||||||
&& rustup target add wasm32-unknown-unknown
|
&& rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
# Prepare the dependency list.
|
# Prepare the dependency list.
|
||||||
@@ -31,26 +30,61 @@ RUN cargo chef prepare --recipe-path /tmp/recipe.json
|
|||||||
FROM chef AS builder
|
FROM chef AS builder
|
||||||
COPY --from=planner /tmp/recipe.json recipe.json
|
COPY --from=planner /tmp/recipe.json recipe.json
|
||||||
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||||
&& cargo chef cook --release -p lldap
|
&& cargo chef cook --release -p lldap \
|
||||||
|
&& cargo chef cook --release -p lldap_migration_tool \
|
||||||
|
&& cargo chef cook --release -p lldap_set_password
|
||||||
|
|
||||||
# Copy the source and build the app and server.
|
# Copy the source and build the app and server.
|
||||||
COPY --chown=app:app . .
|
COPY --chown=app:app . .
|
||||||
RUN cargo build --release -p lldap \
|
RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password \
|
||||||
# Build the frontend.
|
# Build the frontend.
|
||||||
&& ./app/build.sh
|
&& ./app/build.sh
|
||||||
|
|
||||||
# Final image
|
# Final image
|
||||||
FROM alpine:3.14
|
FROM alpine:3.16
|
||||||
|
|
||||||
|
ENV GOSU_VERSION 1.14
|
||||||
|
# Fetch gosu from git
|
||||||
|
RUN set -eux; \
|
||||||
|
\
|
||||||
|
apk add --no-cache --virtual .gosu-deps \
|
||||||
|
ca-certificates \
|
||||||
|
dpkg \
|
||||||
|
gnupg \
|
||||||
|
; \
|
||||||
|
\
|
||||||
|
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||||
|
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||||
|
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
|
||||||
|
\
|
||||||
|
# verify the signature
|
||||||
|
export GNUPGHOME="$(mktemp -d)"; \
|
||||||
|
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||||
|
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
|
||||||
|
command -v gpgconf && gpgconf --kill all || :; \
|
||||||
|
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||||
|
\
|
||||||
|
# clean up fetch dependencies
|
||||||
|
apk del --no-network .gosu-deps; \
|
||||||
|
\
|
||||||
|
chmod +x /usr/local/bin/gosu; \
|
||||||
|
# verify that the binary works
|
||||||
|
gosu --version; \
|
||||||
|
gosu nobody true
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/app/index.html /app/app/main.js /app/app/style.css 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/app/pkg app/pkg
|
||||||
COPY --from=builder /app/target/release/lldap lldap
|
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||||
|
|
||||||
RUN set -x \
|
RUN set -x \
|
||||||
&& apk add --no-cache bash \
|
&& apk add --no-cache bash tzdata \
|
||||||
|
&& for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||||
|
&& for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||||
&& chmod a+r -R .
|
&& chmod a+r -R .
|
||||||
|
|
||||||
ENV LDAP_PORT=3890
|
ENV LDAP_PORT=3890
|
||||||
@@ -60,3 +94,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
|||||||
|
|
||||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||||
|
|||||||
413
README.md
413
README.md
@@ -5,14 +5,15 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
<a href="https://github.com/lldap/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||||
<img
|
<img
|
||||||
src="https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg"
|
src="https://github.com/lldap/lldap/actions/workflows/rust.yml/badge.svg"
|
||||||
alt="Build"/>
|
alt="Build"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/h5PEdRMNyP">
|
<a href="https://discord.gg/h5PEdRMNyP">
|
||||||
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
|
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
|
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
|
||||||
<img
|
<img
|
||||||
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
|
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
|
||||||
@@ -23,30 +24,63 @@
|
|||||||
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
|
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
|
||||||
alt="Unsafe forbidden"/>
|
alt="Unsafe forbidden"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://app.codecov.io/gh/nitnelave/lldap">
|
<a href="https://app.codecov.io/gh/lldap/lldap">
|
||||||
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/nitnelave/lldap" />
|
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/lldap/lldap" />
|
||||||
|
</a>
|
||||||
|
<br/>
|
||||||
|
<a href="https://www.buymeacoffee.com/nitnelave" target="_blank">
|
||||||
|
<img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" >
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
- [About](#about)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [With Docker](#with-docker)
|
||||||
|
- [With Kubernetes](#with-kubernetes)
|
||||||
|
- [From a package repository](#from-a-package-repository)
|
||||||
|
- [From source](#from-source)
|
||||||
|
- [Backend](#backend)
|
||||||
|
- [Frontend](#frontend)
|
||||||
|
- [Cross-compilation](#cross-compilation)
|
||||||
|
- [Usage](#usage)
|
||||||
|
- [Recommended architecture](#recommended-architecture)
|
||||||
|
- [Client configuration](#client-configuration)
|
||||||
|
- [Compatible services](#compatible-services)
|
||||||
|
- [General configuration guide](#general-configuration-guide)
|
||||||
|
- [Sample client configurations](#sample-client-configurations)
|
||||||
|
- [Incompatible services](#incompatible-services)
|
||||||
|
- [Migrating from SQLite](#migrating-from-sqlite)
|
||||||
|
- [Comparisons with other services](#comparisons-with-other-services)
|
||||||
|
- [vs OpenLDAP](#vs-openldap)
|
||||||
|
- [vs FreeIPA](#vs-freeipa)
|
||||||
|
- [vs Kanidm](#vs-kanidm)
|
||||||
|
- [I can't log in!](#i-cant-log-in)
|
||||||
|
- [Contributions](#contributions)
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
This project is a lightweight authentication server that provides an
|
This project is a lightweight authentication server that provides an
|
||||||
opinionated, simplified LDAP interface for authentication. It integrates with
|
opinionated, simplified LDAP interface for authentication. It integrates with
|
||||||
many backends, from KeyCloak to Authelia to Nextcloud and more!
|
many backends, from KeyCloak to Authelia to Nextcloud and
|
||||||
|
[more](#compatible-services)!
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
|
src="https://raw.githubusercontent.com/lldap/lldap/master/screenshot.png"
|
||||||
alt="Screenshot of the user list page"
|
alt="Screenshot of the user list page"
|
||||||
width="50%"
|
width="50%"
|
||||||
align="right"
|
align="right"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
It comes with a frontend that makes user management easy, and allows users to
|
||||||
|
edit their own details or reset their password by email.
|
||||||
|
|
||||||
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
||||||
check out OpenLDAP. This server is a user management system that is:
|
check out OpenLDAP. This server is a user management system that is:
|
||||||
* simple to setup (no messing around with `slapd`),
|
|
||||||
* simple to manage (friendly web UI),
|
- simple to setup (no messing around with `slapd`),
|
||||||
* low resources,
|
- simple to manage (friendly web UI),
|
||||||
* opinionated with basic defaults so you don't have to understand the
|
- low resources,
|
||||||
|
- opinionated with basic defaults so you don't have to understand the
|
||||||
subtleties of LDAP.
|
subtleties of LDAP.
|
||||||
|
|
||||||
It mostly targets self-hosting servers, with open-source components like
|
It mostly targets self-hosting servers, with open-source components like
|
||||||
@@ -57,13 +91,17 @@ For more features (OAuth/OpenID support, reverse proxy, ...) you can install
|
|||||||
other components (KeyCloak, Authelia, ...) using this server as the source of
|
other components (KeyCloak, Authelia, ...) using this server as the source of
|
||||||
truth for users, via LDAP.
|
truth for users, via LDAP.
|
||||||
|
|
||||||
|
By default, the data is stored in SQLite, but you can swap the backend with
|
||||||
|
MySQL/MariaDB or PostgreSQL.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### With Docker
|
### With Docker
|
||||||
|
|
||||||
The image is available at `nitnelave/lldap`. You should persist the `/data`
|
The image is available at `lldap/lldap`. You should persist the `/data`
|
||||||
folder, which contains your configuration, the database and the private key
|
folder, which contains your configuration and the SQLite database (you can
|
||||||
file (unless you move them in the config).
|
remove this step if you use a different DB and configure with environment
|
||||||
|
variables only).
|
||||||
|
|
||||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||||
@@ -71,23 +109,38 @@ Configure the server by copying the `lldap_config.docker_template.toml` to
|
|||||||
Environment variables should be prefixed with `LLDAP_` to override the
|
Environment variables should be prefixed with `LLDAP_` to override the
|
||||||
configuration.
|
configuration.
|
||||||
|
|
||||||
Secrets can also be set through a file. The filename should be specified by the variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_USER_PASS_FILE`, and the file contents are loaded into the respective configuration parameters. Note that `_FILE` variables take precedence.
|
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use
|
||||||
|
default one. The default admin password is `password`, you can change the
|
||||||
|
password later using the web interface.
|
||||||
|
|
||||||
|
Secrets can also be set through a file. The filename should be specified by the
|
||||||
|
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_KEY_SEED_FILE`, and the file
|
||||||
|
contents are loaded into the respective configuration parameters. Note that
|
||||||
|
`_FILE` variables take precedence.
|
||||||
|
|
||||||
Example for docker compose:
|
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
|
```yaml
|
||||||
|
version: "3"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
lldap_data:
|
lldap_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
services:
|
services:
|
||||||
lldap:
|
lldap:
|
||||||
image: nitnelave/lldap
|
image: lldap/lldap:stable
|
||||||
# Change this to the user:group you want.
|
|
||||||
user: "33:33"
|
|
||||||
ports:
|
ports:
|
||||||
# For LDAP
|
# For LDAP, not recommended to expose, see Usage section.
|
||||||
- "3890:3890"
|
#- "3890:3890"
|
||||||
|
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
|
||||||
|
#- "6360:6360"
|
||||||
# For the web front-end
|
# For the web front-end
|
||||||
- "17170:17170"
|
- "17170:17170"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -95,38 +148,111 @@ services:
|
|||||||
# Alternatively, you can mount a local folder
|
# Alternatively, you can mount a local folder
|
||||||
# - "./lldap_data:/data"
|
# - "./lldap_data:/data"
|
||||||
environment:
|
environment:
|
||||||
|
- UID=####
|
||||||
|
- GID=####
|
||||||
|
- TZ=####/####
|
||||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
||||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
# If using LDAPS, set enabled true and configure cert and key path
|
||||||
|
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||||
|
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
||||||
|
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
|
||||||
|
# You can also set a different database:
|
||||||
|
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||||
|
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||||
```
|
```
|
||||||
|
|
||||||
Then the service will listen on two ports, one for LDAP and one for the web
|
Then the service will listen on two ports, one for LDAP and one for the web
|
||||||
front-end.
|
front-end.
|
||||||
|
|
||||||
|
### With Kubernetes
|
||||||
|
|
||||||
|
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
||||||
|
|
||||||
|
You can bootstrap your lldap instance (users, groups)
|
||||||
|
using [bootstrap.sh](example_configs/bootstrap/bootstrap.md#kubernetes-job).
|
||||||
|
It can be run by Argo CD for managing users in git-opt way, or as a one-shot job.
|
||||||
|
|
||||||
|
### From a package repository
|
||||||
|
|
||||||
|
**Do not open issues in this repository for problems with third-party
|
||||||
|
pre-built packages. Report issues downstream.**
|
||||||
|
|
||||||
|
Depending on the distribution you use, it might be possible to install lldap
|
||||||
|
from a package repository, officially supported by the distribution or
|
||||||
|
community contributed.
|
||||||
|
|
||||||
|
#### Debian, CentOS Fedora, OpenSUSE, Ubuntu
|
||||||
|
|
||||||
|
The package for these distributions can be found at [LLDAP OBS](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap).
|
||||||
|
- When using the distributed package, the default login is `admin/password`. You can change that from the web UI after starting the service.
|
||||||
|
|
||||||
|
#### Arch Linux
|
||||||
|
|
||||||
|
Arch Linux offers unofficial support through the [Arch User Repository
|
||||||
|
(AUR)](https://wiki.archlinux.org/title/Arch_User_Repository).
|
||||||
|
Available package descriptions in AUR are:
|
||||||
|
|
||||||
|
- [lldap](https://aur.archlinux.org/packages/lldap) - Builds the latest stable version.
|
||||||
|
- [lldap-bin](https://aur.archlinux.org/packages/lldap-bin) - Uses the latest
|
||||||
|
pre-compiled binaries from the [releases in this repository](https://github.com/lldap/lldap/releases).
|
||||||
|
This package is recommended if you want to run lldap on a system with
|
||||||
|
limited resources.
|
||||||
|
- [lldap-git](https://aur.archlinux.org/packages/lldap-git) - Builds the
|
||||||
|
latest main branch code.
|
||||||
|
|
||||||
|
The package descriptions can be used
|
||||||
|
[to create and install packages](https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started).
|
||||||
|
Each package places lldap's configuration file at `/etc/lldap.toml` and offers
|
||||||
|
[systemd service](https://wiki.archlinux.org/title/systemd#Using_units)
|
||||||
|
`lldap.service` to (auto-)start and stop lldap.
|
||||||
|
|
||||||
### From source
|
### 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
|
To bring up the server, you'll need to compile the frontend. In addition to
|
||||||
cargo, you'll need:
|
`cargo`, you'll need WASM-pack, which can be installed by running `cargo install wasm-pack`.
|
||||||
|
|
||||||
* WASM-pack: `cargo install wasm-pack`
|
Then you can build the frontend files with
|
||||||
* rollup.js: `npm install rollup`
|
|
||||||
|
|
||||||
Then you can build the frontend files with `./app/build.sh` (you'll need to run
|
```shell
|
||||||
this after every front-end change to update the WASM package served).
|
./app/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
To bring up the server, just run `cargo run`. The default config is in
|
(you'll need to run this after every front-end change to update the WASM
|
||||||
`src/infra/configuration.rs`, but you can override it by creating an
|
package served).
|
||||||
`lldap_config.toml`, setting environment variables or passing arguments to
|
|
||||||
`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`. 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
|
### Cross-compilation
|
||||||
|
|
||||||
No Docker image is provided for other architectures, due to the difficulty of
|
Docker images are provided for AMD64, ARM64 and ARM/V7.
|
||||||
setting up cross-compilation inside a Docker image.
|
|
||||||
|
|
||||||
Some pre-compiled binaries are provided for each release, starting with 0.2.
|
If you want to cross-compile yourself, you can do so by installing
|
||||||
|
|
||||||
If you want to cross-compile, you can do so by installing
|
|
||||||
[`cross`](https://github.com/rust-embedded/cross):
|
[`cross`](https://github.com/rust-embedded/cross):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -144,82 +270,229 @@ You can then get the compiled server binary in
|
|||||||
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||||
files in an `app` folder next to the binary).
|
files in an `app` folder next to the binary).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The simplest way to use LLDAP is through the web front-end. There you can
|
||||||
|
create users, set passwords, add them to groups and so on. Users can also
|
||||||
|
connect to the web UI and change their information, or request a password reset
|
||||||
|
link (if you configured the SMTP client).
|
||||||
|
|
||||||
|
Creating and managing custom attributes is currently in Beta. It's not
|
||||||
|
supported in the Web UI. The recommended way is to use
|
||||||
|
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli), a
|
||||||
|
community-contributed CLI frontend.
|
||||||
|
|
||||||
|
LLDAP is also very scriptable, through its GraphQL API. See the
|
||||||
|
[Scripting](docs/scripting.md) docs for more info.
|
||||||
|
|
||||||
|
### Recommended architecture
|
||||||
|
|
||||||
|
If you are using containers, a sample architecture could look like this:
|
||||||
|
|
||||||
|
- A reverse proxy (e.g. nginx or Traefik)
|
||||||
|
- An authentication service (e.g. Authelia, Authentik or KeyCloak) connected to
|
||||||
|
LLDAP to provide authentication for non-authenticated services, or to provide
|
||||||
|
SSO with compatible ones.
|
||||||
|
- The LLDAP service, with the web port exposed to Traefik.
|
||||||
|
- The LDAP port doesn't need to be exposed, since only the other containers
|
||||||
|
will access it.
|
||||||
|
- You can also set up LDAPS if you want to expose the LDAP port to the
|
||||||
|
internet (not recommended) or for an extra layer of security in the
|
||||||
|
inter-container communication (though it's very much optional).
|
||||||
|
- The default LLDAP container starts up as root to fix up some files'
|
||||||
|
permissions before downgrading the privilege to the given user. However,
|
||||||
|
you can (should?) use the `*-rootless` version of the images to be able to
|
||||||
|
start directly as that user, once you got the permissions right. Just don't
|
||||||
|
forget to change from the `UID/GID` env vars to the `uid` docker-compose
|
||||||
|
field.
|
||||||
|
- Any other service that needs to connect to LLDAP for authentication (e.g.
|
||||||
|
NextCloud) can be added to a shared network with LLDAP. The finest
|
||||||
|
granularity is a network for each pair of LLDAP-service, but there are often
|
||||||
|
coarser granularities that make sense (e.g. a network for the \*arr stack and
|
||||||
|
LLDAP).
|
||||||
|
|
||||||
## Client configuration
|
## Client configuration
|
||||||
|
|
||||||
|
### Compatible services
|
||||||
|
|
||||||
|
Most services that can use LDAP as an authentication provider should work out
|
||||||
|
of the box. For new services, it's possible that they require a bit of tweaking
|
||||||
|
on LLDAP's side to make things work. In that case, just create an issue with
|
||||||
|
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
|
||||||
|
the config).
|
||||||
|
|
||||||
|
### General configuration guide
|
||||||
|
|
||||||
To configure the services that will talk to LLDAP, here are the values:
|
To configure the services that will talk to LLDAP, here are the values:
|
||||||
- The LDAP user DN is from the configuration. By default,
|
|
||||||
`cn=admin,ou=people,dc=example,dc=com`.
|
- The LDAP user DN is from the configuration. By default,
|
||||||
- The LDAP password is from the configuration (same as to log in to the web
|
`cn=admin,ou=people,dc=example,dc=com`.
|
||||||
UI).
|
- The LDAP password is from the configuration (same as to log in to the web
|
||||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
UI).
|
||||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||||
|
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||||
|
|
||||||
Testing group membership through `memberOf` is supported, so you can have a
|
Testing group membership through `memberOf` is supported, so you can have a
|
||||||
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||||
|
|
||||||
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
||||||
admin rights in the Web UI.
|
admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
||||||
|
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||||
|
administration access to many services.
|
||||||
|
|
||||||
### Sample client configurations
|
### Sample client configurations
|
||||||
|
|
||||||
Some specific clients have been tested to work and come with sample
|
Some specific clients have been tested to work and come with sample
|
||||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||||
folder for help with:
|
folder for help with:
|
||||||
- [Authelia](example_configs/authelia_config.yml)
|
|
||||||
- [KeyCloak](example_configs/keycloak.md)
|
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||||
- [Jisti Meet](example_configs/jitsi_meet.conf)
|
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||||
|
- [Apereo CAS Server](example_configs/apereo_cas_server.md)
|
||||||
|
- [Authelia](example_configs/authelia_config.yml)
|
||||||
|
- [Authentik](example_configs/authentik.md)
|
||||||
|
- [Bookstack](example_configs/bookstack.env.example)
|
||||||
|
- [Calibre-Web](example_configs/calibre_web.md)
|
||||||
|
- [Dell iDRAC](example_configs/dell_idrac.md)
|
||||||
|
- [Dex](example_configs/dex_config.yml)
|
||||||
|
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||||
|
- [Dolibarr](example_configs/dolibarr.md)
|
||||||
|
- [Ejabberd](example_configs/ejabberd.md)
|
||||||
|
- [Emby](example_configs/emby.md)
|
||||||
|
- [Ergo IRCd](example_configs/ergo.md)
|
||||||
|
- [Gitea](example_configs/gitea.md)
|
||||||
|
- [GitLab](example_configs/gitlab.md)
|
||||||
|
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||||
|
- [Grocy](example_configs/grocy.md)
|
||||||
|
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||||
|
- [Home Assistant](example_configs/home-assistant.md)
|
||||||
|
- [Jellyfin](example_configs/jellyfin.md)
|
||||||
|
- [Jenkins](example_configs/jenkins.md)
|
||||||
|
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||||
|
- [Kasm](example_configs/kasm.md)
|
||||||
|
- [KeyCloak](example_configs/keycloak.md)
|
||||||
|
- [LibreNMS](example_configs/librenms.md)
|
||||||
|
- [Maddy](example_configs/maddy.md)
|
||||||
|
- [Mastodon](example_configs/mastodon.env.example)
|
||||||
|
- [Matrix](example_configs/matrix_synapse.yml)
|
||||||
|
- [Mealie](example_configs/mealie.md)
|
||||||
|
- [MinIO](example_configs/minio.md)
|
||||||
|
- [Nextcloud](example_configs/nextcloud.md)
|
||||||
|
- [Nexus](example_configs/nexus.md)
|
||||||
|
- [Organizr](example_configs/Organizr.md)
|
||||||
|
- [Portainer](example_configs/portainer.md)
|
||||||
|
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
||||||
|
- [Proxmox VE](example_configs/proxmox.md)
|
||||||
|
- [Radicale](example_configs/radicale.md)
|
||||||
|
- [Rancher](example_configs/rancher.md)
|
||||||
|
- [Seafile](example_configs/seafile.md)
|
||||||
|
- [Shaarli](example_configs/shaarli.md)
|
||||||
|
- [Squid](example_configs/squid.md)
|
||||||
|
- [Syncthing](example_configs/syncthing.md)
|
||||||
|
- [TheLounge](example_configs/thelounge.md)
|
||||||
|
- [Traccar](example_configs/traccar.xml)
|
||||||
|
- [Vaultwarden](example_configs/vaultwarden.md)
|
||||||
|
- [WeKan](example_configs/wekan.md)
|
||||||
|
- [WG Portal](example_configs/wg_portal.env.example)
|
||||||
|
- [WikiJS](example_configs/wikijs.md)
|
||||||
|
- [XBackBone](example_configs/xbackbone_config.php)
|
||||||
|
- [Zendto](example_configs/zendto.md)
|
||||||
|
- [Zitadel](example_configs/zitadel.md)
|
||||||
|
- [Zulip](example_configs/zulip.md)
|
||||||
|
|
||||||
|
### Incompatible services
|
||||||
|
|
||||||
|
Though we try to be maximally compatible, not every feature is supported; LLDAP
|
||||||
|
is not a fully-featured LDAP server, intentionally so.
|
||||||
|
|
||||||
|
LDAP browsing tools are generally not supported, though they could be. If you
|
||||||
|
need to use one but it behaves weirdly, please file a bug.
|
||||||
|
|
||||||
|
Some services use features that are not implemented, or require specific
|
||||||
|
attributes. You can try to create those attributes (see custom attributes in
|
||||||
|
the [Usage](#usage) section).
|
||||||
|
|
||||||
|
Finally, some services require password hashes so they can validate themselves
|
||||||
|
the user's password without contacting LLDAP. This is not and will not be
|
||||||
|
supported, it's incompatible with our password hashing scheme (a zero-knowledge
|
||||||
|
proof). Furthermore, it's generally not recommended in terms of security, since
|
||||||
|
it duplicates the places from which a password hash could leak.
|
||||||
|
|
||||||
|
In that category, the most prominent is Synology. It is, to date, the only
|
||||||
|
service that seems definitely incompatible with LLDAP.
|
||||||
|
|
||||||
|
## Migrating from SQLite
|
||||||
|
|
||||||
|
If you started with an SQLite database and would like to migrate to
|
||||||
|
MySQL/MariaDB or PostgreSQL, check out the [DB
|
||||||
|
migration docs](/docs/database_migration.md).
|
||||||
|
|
||||||
## Comparisons with other services
|
## Comparisons with other services
|
||||||
|
|
||||||
### vs OpenLDAP
|
### vs OpenLDAP
|
||||||
|
|
||||||
OpenLDAP is a monster of a service that implements all of LDAP and all of its
|
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
|
||||||
extensions, plus some of its own. That said, if you need all that flexibility,
|
all of LDAP and all of its extensions, plus some of its own. That said, if you
|
||||||
it might be what you need! Note that installation can be a bit painful
|
need all that flexibility, it might be what you need! Note that installation
|
||||||
(figuring out how to use `slapd`) and people have mixed experiences following
|
can be a bit painful (figuring out how to use `slapd`) and people have mixed
|
||||||
tutorials online. If you don't configure it properly, you might end up storing
|
experiences following tutorials online. If you don't configure it properly, you
|
||||||
passwords in clear, so a breach of your server would reveal all the stored
|
might end up storing passwords in clear, so a breach of your server would
|
||||||
passwords!
|
reveal all the stored passwords!
|
||||||
|
|
||||||
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
||||||
install one (not that many that look nice) and configure it.
|
install one (not that many look nice) and configure it.
|
||||||
|
|
||||||
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
||||||
you add PhpLdapAdmin), and comes packed with its own purpose-built 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
|
### 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
|
[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, it also does security policies, single sign-on, certificate
|
||||||
management, linux account management and so on.
|
management, linux account management and so on.
|
||||||
|
|
||||||
If you need all of that, go for it! Keep in mind that a more complex system is
|
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||||
more complex to maintain, though.
|
more complex to maintain, though.
|
||||||
|
|
||||||
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
|
configure (no messing around with DNS or security policies) and simpler to
|
||||||
use. It also comes conveniently packed in a docker container.
|
use. It also comes conveniently packed in a docker container.
|
||||||
|
|
||||||
|
### vs Kanidm
|
||||||
|
|
||||||
|
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
|
||||||
|
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
|
||||||
|
WebAuthn. It comes with a (read-only) LDAPS server.
|
||||||
|
|
||||||
|
It's fairly easy to install and does much more; but their LDAP server is
|
||||||
|
read-only, and by having more moving parts it is inherently more complex. If
|
||||||
|
you don't need to modify the users through LDAP and you're planning on
|
||||||
|
installing something like [KeyCloak](https://www.keycloak.org) to provide
|
||||||
|
modern identity protocols, check out Kanidm.
|
||||||
|
|
||||||
## I can't log in!
|
## I can't log in!
|
||||||
|
|
||||||
If you just set up the server, can get to the login page but the password you
|
If you just set up the server, can get to the login page but the password you
|
||||||
set isn't working, try the following:
|
set isn't working, try the following:
|
||||||
|
|
||||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||||
docker volume or mounted from the host filesystem.
|
docker volume or mounted from the host filesystem.
|
||||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||||
or in the current directory). If there isn't, copy
|
or in the current directory). If there isn't, copy
|
||||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||||
(passwords, secrets, ...).
|
(passwords, secrets, ...).
|
||||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
- 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
|
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
|
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
|
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.
|
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||||
- Make sure you restart the server.
|
- Make sure you restart the server.
|
||||||
- If it's still not working, join the [Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
- If it's still not working, join the
|
||||||
|
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,52 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lldap_app"
|
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||||
version = "0.2.0"
|
description = "Frontend for LLDAP"
|
||||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
homepage = "https://github.com/lldap/lldap"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
name = "lldap_app"
|
||||||
|
repository = "https://github.com/lldap/lldap"
|
||||||
|
version = "0.5.1-alpha"
|
||||||
|
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
base64 = "0.13"
|
||||||
|
gloo-console = "0.2.3"
|
||||||
|
gloo-file = "0.2.3"
|
||||||
|
gloo-net = "*"
|
||||||
graphql_client = "0.10"
|
graphql_client = "0.10"
|
||||||
http = "0.2"
|
http = "0.2"
|
||||||
jwt = "0.13"
|
jwt = "0.13"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
validator = "*"
|
url-escape = "0.1.1"
|
||||||
|
validator = "=0.14"
|
||||||
validator_derive = "*"
|
validator_derive = "*"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
yew = "0.18"
|
wasm-bindgen-futures = "*"
|
||||||
yewtil = "*"
|
yew = "0.19.3"
|
||||||
yew-router = "0.15"
|
yew-router = "0.16"
|
||||||
yew_form = "0.1.8"
|
|
||||||
yew_form_derive = "*"
|
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
||||||
|
indexmap = "=1.6.2"
|
||||||
|
|
||||||
[dependencies.web-sys]
|
[dependencies.web-sys]
|
||||||
version = "0.3"
|
version = "0.3"
|
||||||
features = [
|
features = [
|
||||||
"Document",
|
"Document",
|
||||||
"Element",
|
"Element",
|
||||||
|
"Event",
|
||||||
|
"FileReader",
|
||||||
|
"FormData",
|
||||||
"HtmlDocument",
|
"HtmlDocument",
|
||||||
|
"HtmlFormElement",
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
"HtmlOptionElement",
|
"HtmlOptionElement",
|
||||||
"HtmlOptionsCollection",
|
"HtmlOptionsCollection",
|
||||||
"HtmlSelectElement",
|
"HtmlSelectElement",
|
||||||
|
"SubmitEvent",
|
||||||
"console",
|
"console",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -44,5 +60,18 @@ features = [
|
|||||||
path = "../auth"
|
path = "../auth"
|
||||||
features = [ "opaque_client" ]
|
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]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|||||||
20
app/build.sh
20
app/build.sh
@@ -6,22 +6,12 @@ then
|
|||||||
>&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`'
|
>&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! which gzip > /dev/null 2>&1
|
||||||
wasm-pack build --target web
|
|
||||||
|
|
||||||
ROLLUP_BIN=$(which rollup 2>/dev/null)
|
|
||||||
if [ -f ../node_modules/rollup/dist/bin/rollup ]
|
|
||||||
then
|
then
|
||||||
ROLLUP_BIN=../node_modules/rollup/dist/bin/rollup
|
>&2 echo '`gzip` not found.'
|
||||||
elif [ -f node_modules/rollup/dist/bin/rollup ]
|
|
||||||
then
|
|
||||||
ROLLUP_BIN=node_modules/rollup/dist/bin/rollup
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$ROLLUP_BIN" ]
|
|
||||||
then
|
|
||||||
>&2 echo '`rollup` not found. Try running `npm install rollup`'
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js
|
wasm-pack build --target web --release
|
||||||
|
|
||||||
|
gzip -9 -k -f pkg/lldap_app_bg.wasm
|
||||||
|
|||||||
@@ -4,29 +4,63 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>LLDAP Administration</title>
|
<title>LLDAP Administration</title>
|
||||||
<script src="/pkg/bundle.js" defer></script>
|
<base href="/">
|
||||||
|
<script src="static/main.js" type="module" defer></script>
|
||||||
<link
|
<link
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
||||||
rel="preload stylesheet"
|
rel="preload stylesheet"
|
||||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
as="style" />
|
as="style" />
|
||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
|
||||||
|
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
||||||
as="style" />
|
integrity="sha384-tKLJeE1ALTUwtXlaGjJYM3sejfssWdAaWR2s97axw4xkiAdMzQjtOjgcyw0Y50KU"
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
crossorigin="anonymous" as="style" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
|
||||||
|
crossorigin="anonymous" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="static/style.css" />
|
||||||
|
<script>
|
||||||
|
function inDarkMode(){
|
||||||
|
return darkmode.inDarkMode;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/style.css">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
|
||||||
|
LLDAP requires JavaScript, please switch to a compatible browser or
|
||||||
|
enable it.
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Detect if the user has WASM support. */
|
||||||
|
if (typeof WebAssembly === 'undefined') {
|
||||||
|
const pWASMMsg = document.createElement("p")
|
||||||
|
pWASMMsg.innerHTML = `
|
||||||
|
LLDAP requires WASM and JIT for JavaScript, please switch to a
|
||||||
|
compatible browser or enable it.
|
||||||
|
`
|
||||||
|
document.body.appendChild(pWASMMsg)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
62
app/index_local.html
Normal file
62
app/index_local.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>LLDAP Administration</title>
|
||||||
|
<script src="/static/main.js" type="module" defer></script>
|
||||||
|
<link
|
||||||
|
href="/static/bootstrap-nightshade.min.css"
|
||||||
|
rel="preload stylesheet"
|
||||||
|
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"
|
||||||
|
integrity="sha384-tKLJeE1ALTUwtXlaGjJYM3sejfssWdAaWR2s97axw4xkiAdMzQjtOjgcyw0Y50KU"
|
||||||
|
as="style" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN"
|
||||||
|
href="/static/font-awesome.min.css" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/static/fonts.css" />
|
||||||
|
<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()
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/queries/get_group_attributes_schema.graphql
Normal file
13
app/queries/get_group_attributes_schema.graphql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
query GetGroupAttributesSchema {
|
||||||
|
schema {
|
||||||
|
groupSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isHardcoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ query GetGroupDetails($id: Int!) {
|
|||||||
group(groupId: $id) {
|
group(groupId: $id) {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
|
creationDate
|
||||||
|
uuid
|
||||||
users {
|
users {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ query GetGroupList {
|
|||||||
groups {
|
groups {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
|
creationDate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
app/queries/get_user_attributes_schema.graphql
Normal file
14
app/queries/get_user_attributes_schema.graphql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
query GetUserAttributesSchema {
|
||||||
|
schema {
|
||||||
|
userSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,24 @@ query GetUserDetails($id: String!) {
|
|||||||
displayName
|
displayName
|
||||||
firstName
|
firstName
|
||||||
lastName
|
lastName
|
||||||
|
avatar
|
||||||
creationDate
|
creationDate
|
||||||
|
uuid
|
||||||
groups {
|
groups {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
schema {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,23 +52,25 @@ pub struct Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserListResponse(response) => {
|
Msg::UserListResponse(response) => {
|
||||||
self.user_list = Some(response?.users);
|
self.user_list = Some(response?.users);
|
||||||
self.common.cancel_task();
|
|
||||||
}
|
}
|
||||||
Msg::SubmitAddMember => return self.submit_add_member(),
|
Msg::SubmitAddMember => return self.submit_add_member(ctx),
|
||||||
Msg::AddMemberResponse(response) => {
|
Msg::AddMemberResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.common.cancel_task();
|
|
||||||
let user = self
|
let user = self
|
||||||
.selected_user
|
.selected_user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("Could not get selected user")
|
.expect("Could not get selected user")
|
||||||
.clone();
|
.clone();
|
||||||
// Remove the user from the dropdown.
|
// Remove the user from the dropdown.
|
||||||
self.common.on_user_added_to_group.emit(user);
|
ctx.props().on_user_added_to_group.emit(user);
|
||||||
}
|
}
|
||||||
Msg::SelectionChanged(option_props) => {
|
Msg::SelectionChanged(option_props) => {
|
||||||
let was_some = self.selected_user.is_some();
|
let was_some = self.selected_user.is_some();
|
||||||
@@ -88,23 +90,25 @@ impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AddGroupMemberComponent {
|
impl AddGroupMemberComponent {
|
||||||
fn get_user_list(&mut self) {
|
fn get_user_list(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<ListUserNames, _>(
|
self.common.call_graphql::<ListUserNames, _>(
|
||||||
|
ctx,
|
||||||
list_user_names::Variables { filters: None },
|
list_user_names::Variables { filters: None },
|
||||||
Msg::UserListResponse,
|
Msg::UserListResponse,
|
||||||
"Error trying to fetch user list",
|
"Error trying to fetch user list",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit_add_member(&mut self) -> Result<bool> {
|
fn submit_add_member(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
let user_id = match self.selected_user.clone() {
|
let user_id = match self.selected_user.clone() {
|
||||||
None => return Ok(false),
|
None => return Ok(false),
|
||||||
Some(user) => user.id,
|
Some(user) => user.id,
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<AddUserToGroup, _>(
|
self.common.call_graphql::<AddUserToGroup, _>(
|
||||||
|
ctx,
|
||||||
add_user_to_group::Variables {
|
add_user_to_group::Variables {
|
||||||
user: user_id,
|
user: user_id,
|
||||||
group: self.common.group_id,
|
group: ctx.props().group_id,
|
||||||
},
|
},
|
||||||
Msg::AddMemberResponse,
|
Msg::AddMemberResponse,
|
||||||
"Error trying to initiate adding the user to a group",
|
"Error trying to initiate adding the user to a group",
|
||||||
@@ -112,8 +116,8 @@ impl AddGroupMemberComponent {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
|
fn get_selectable_user_list(&self, ctx: &Context<Self>, user_list: &[User]) -> Vec<User> {
|
||||||
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
|
let user_groups = ctx.props().users.iter().collect::<HashSet<_>>();
|
||||||
user_list
|
user_list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|u| !user_groups.contains(u))
|
.filter(|u| !user_groups.contains(u))
|
||||||
@@ -126,41 +130,39 @@ impl Component for AddGroupMemberComponent {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut res = Self {
|
let mut res = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
user_list: None,
|
user_list: None,
|
||||||
selected_user: None,
|
selected_user: None,
|
||||||
};
|
};
|
||||||
res.get_user_list();
|
res.get_user_list(ctx);
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
if let Some(user_list) = &self.user_list {
|
if let Some(user_list) = &self.user_list {
|
||||||
let to_add_user_list = self.get_selectable_user_list(user_list);
|
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||||
#[allow(unused_braces)]
|
#[allow(unused_braces)]
|
||||||
let make_select_option = |user: User| {
|
let make_select_option = |user: User| {
|
||||||
html_nested! {
|
html_nested! {
|
||||||
<SelectOption value=user.id.clone() text=user.display_name.clone() key=user.id />
|
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-3">
|
||||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||||
{
|
{
|
||||||
to_add_user_list
|
to_add_user_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -169,12 +171,13 @@ impl Component for AddGroupMemberComponent {
|
|||||||
}
|
}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-1">
|
<div class="col-3">
|
||||||
<button
|
<button
|
||||||
class="btn btn-success"
|
class="btn btn-secondary"
|
||||||
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
disabled={self.selected_user.is_none() || self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
onclick={link.callback(|_| Msg::SubmitAddMember)}>
|
||||||
{"Add"}
|
<i class="bi-person-plus me-2"></i>
|
||||||
|
{"Add to group"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,16 +64,18 @@ pub struct Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::GroupListResponse(response) => {
|
Msg::GroupListResponse(response) => {
|
||||||
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
||||||
self.common.cancel_task();
|
|
||||||
}
|
}
|
||||||
Msg::SubmitAddGroup => return self.submit_add_group(),
|
Msg::SubmitAddGroup => return self.submit_add_group(ctx),
|
||||||
Msg::AddGroupResponse(response) => {
|
Msg::AddGroupResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.common.cancel_task();
|
|
||||||
// Adding the user to the group succeeded, we're not in the process of adding a
|
// Adding the user to the group succeeded, we're not in the process of adding a
|
||||||
// group anymore.
|
// group anymore.
|
||||||
let group = self
|
let group = self
|
||||||
@@ -82,7 +84,7 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
|||||||
.expect("Could not get selected group")
|
.expect("Could not get selected group")
|
||||||
.clone();
|
.clone();
|
||||||
// Remove the group from the dropdown.
|
// Remove the group from the dropdown.
|
||||||
self.common.on_user_added_to_group.emit(group);
|
ctx.props().on_user_added_to_group.emit(group);
|
||||||
}
|
}
|
||||||
Msg::SelectionChanged(option_props) => {
|
Msg::SelectionChanged(option_props) => {
|
||||||
let was_some = self.selected_group.is_some();
|
let was_some = self.selected_group.is_some();
|
||||||
@@ -102,22 +104,24 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AddUserToGroupComponent {
|
impl AddUserToGroupComponent {
|
||||||
fn get_group_list(&mut self) {
|
fn get_group_list(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<GetGroupList, _>(
|
self.common.call_graphql::<GetGroupList, _>(
|
||||||
|
ctx,
|
||||||
get_group_list::Variables,
|
get_group_list::Variables,
|
||||||
Msg::GroupListResponse,
|
Msg::GroupListResponse,
|
||||||
"Error trying to fetch group list",
|
"Error trying to fetch group list",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit_add_group(&mut self) -> Result<bool> {
|
fn submit_add_group(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
let group_id = match &self.selected_group {
|
let group_id = match &self.selected_group {
|
||||||
None => return Ok(false),
|
None => return Ok(false),
|
||||||
Some(group) => group.id,
|
Some(group) => group.id,
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<AddUserToGroup, _>(
|
self.common.call_graphql::<AddUserToGroup, _>(
|
||||||
|
ctx,
|
||||||
add_user_to_group::Variables {
|
add_user_to_group::Variables {
|
||||||
user: self.common.username.clone(),
|
user: ctx.props().username.clone(),
|
||||||
group: group_id,
|
group: group_id,
|
||||||
},
|
},
|
||||||
Msg::AddGroupResponse,
|
Msg::AddGroupResponse,
|
||||||
@@ -126,8 +130,8 @@ impl AddUserToGroupComponent {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
|
fn get_selectable_group_list(&self, props: &Props, group_list: &[Group]) -> Vec<Group> {
|
||||||
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
|
let user_groups = props.groups.iter().collect::<HashSet<_>>();
|
||||||
group_list
|
group_list
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|g| !user_groups.contains(g))
|
.filter(|g| !user_groups.contains(g))
|
||||||
@@ -139,41 +143,39 @@ impl AddUserToGroupComponent {
|
|||||||
impl Component for AddUserToGroupComponent {
|
impl Component for AddUserToGroupComponent {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut res = Self {
|
let mut res = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
group_list: None,
|
group_list: None,
|
||||||
selected_group: None,
|
selected_group: None,
|
||||||
};
|
};
|
||||||
res.get_group_list();
|
res.get_group_list(ctx);
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
if let Some(group_list) = &self.group_list {
|
if let Some(group_list) = &self.group_list {
|
||||||
let to_add_group_list = self.get_selectable_group_list(group_list);
|
let to_add_group_list = self.get_selectable_group_list(ctx.props(), group_list);
|
||||||
#[allow(unused_braces)]
|
#[allow(unused_braces)]
|
||||||
let make_select_option = |group: Group| {
|
let make_select_option = |group: Group| {
|
||||||
html_nested! {
|
html_nested! {
|
||||||
<SelectOption value=group.id.to_string() text=group.display_name key=group.id />
|
<SelectOption value={group.id.to_string()} text={group.display_name} key={group.id} />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-3">
|
||||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||||
{
|
{
|
||||||
to_add_group_list
|
to_add_group_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -182,12 +184,13 @@ impl Component for AddUserToGroupComponent {
|
|||||||
}
|
}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-1">
|
<div class="col-sm-3">
|
||||||
<button
|
<button
|
||||||
class="btn btn-success"
|
class="btn btn-secondary"
|
||||||
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
disabled={self.selected_group.is_none() || self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
onclick={link.callback(|_| Msg::SubmitAddGroup)}>
|
||||||
{"Add"}
|
<i class="bi-person-plus me-2"></i>
|
||||||
|
{"Add to group"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,168 +1,201 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
|
banner::Banner,
|
||||||
change_password::ChangePasswordForm,
|
change_password::ChangePasswordForm,
|
||||||
create_group::CreateGroupForm,
|
create_group::CreateGroupForm,
|
||||||
|
create_group_attribute::CreateGroupAttributeForm,
|
||||||
create_user::CreateUserForm,
|
create_user::CreateUserForm,
|
||||||
|
create_user_attribute::CreateUserAttributeForm,
|
||||||
group_details::GroupDetails,
|
group_details::GroupDetails,
|
||||||
|
group_schema_table::ListGroupSchema,
|
||||||
group_table::GroupTable,
|
group_table::GroupTable,
|
||||||
login::LoginForm,
|
login::LoginForm,
|
||||||
logout::LogoutButton,
|
|
||||||
reset_password_step1::ResetPasswordStep1Form,
|
reset_password_step1::ResetPasswordStep1Form,
|
||||||
reset_password_step2::ResetPasswordStep2Form,
|
reset_password_step2::ResetPasswordStep2Form,
|
||||||
router::{AppRoute, Link, NavButton},
|
router::{AppRoute, Link, Redirect},
|
||||||
user_details::UserDetails,
|
user_details::UserDetails,
|
||||||
|
user_schema_table::ListUserSchema,
|
||||||
user_table::UserTable,
|
user_table::UserTable,
|
||||||
},
|
},
|
||||||
infra::cookies::get_cookie,
|
infra::{api::HostService, cookies::get_cookie},
|
||||||
};
|
|
||||||
use yew::prelude::*;
|
|
||||||
use yew::services::ConsoleService;
|
|
||||||
use yew_router::{
|
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
router::Router,
|
|
||||||
service::RouteService,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use gloo_console::error;
|
||||||
|
use yew::{
|
||||||
|
function_component,
|
||||||
|
html::Scope,
|
||||||
|
prelude::{html, Component, Html},
|
||||||
|
Context,
|
||||||
|
};
|
||||||
|
use yew_router::{
|
||||||
|
prelude::{History, Location},
|
||||||
|
scope_ext::RouterScopeExt,
|
||||||
|
BrowserRouter, Switch,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[function_component(AppContainer)]
|
||||||
|
pub fn app_container() -> Html {
|
||||||
|
html! {
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
link: ComponentLink<Self>,
|
|
||||||
user_info: Option<(String, bool)>,
|
user_info: Option<(String, bool)>,
|
||||||
redirect_to: Option<AppRoute>,
|
redirect_to: Option<AppRoute>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
password_reset_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Login((String, bool)),
|
Login((String, bool)),
|
||||||
Logout,
|
Logout,
|
||||||
|
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for App {
|
impl Component for App {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut app = Self {
|
let app = Self {
|
||||||
link,
|
|
||||||
user_info: get_cookie("user_id")
|
user_info: get_cookie("user_id")
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
ConsoleService::error(&e.to_string());
|
error!(&e.to_string());
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
.and_then(|u| {
|
.and_then(|u| {
|
||||||
get_cookie("is_admin")
|
get_cookie("is_admin")
|
||||||
.map(|so| so.map(|s| (u, s == "true")))
|
.map(|so| so.map(|s| (u, s == "true")))
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
ConsoleService::error(&e.to_string());
|
error!(&e.to_string());
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
redirect_to: Self::get_redirect_route(),
|
redirect_to: Self::get_redirect_route(ctx),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
password_reset_enabled: None,
|
||||||
};
|
};
|
||||||
app.apply_initial_redirections();
|
ctx.link().send_future(async move {
|
||||||
|
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
|
||||||
|
});
|
||||||
|
app.apply_initial_redirections(ctx);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
let history = ctx.link().history().unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Login((user_name, is_admin)) => {
|
Msg::Login((user_name, is_admin)) => {
|
||||||
self.user_info = Some((user_name.clone(), is_admin));
|
self.user_info = Some((user_name.clone(), is_admin));
|
||||||
self.route_dispatcher
|
history.push(self.redirect_to.take().unwrap_or_else(|| {
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(
|
if is_admin {
|
||||||
self.redirect_to.take().unwrap_or_else(|| {
|
AppRoute::ListUsers
|
||||||
if is_admin {
|
} else {
|
||||||
AppRoute::ListUsers
|
AppRoute::UserDetails {
|
||||||
} else {
|
user_id: user_name.clone(),
|
||||||
AppRoute::UserDetails(user_name.clone())
|
}
|
||||||
}
|
}
|
||||||
}),
|
}));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
Msg::Logout => {
|
Msg::Logout => {
|
||||||
self.user_info = None;
|
self.user_info = None;
|
||||||
self.redirect_to = None;
|
self.redirect_to = None;
|
||||||
|
history.push(AppRoute::Login);
|
||||||
|
}
|
||||||
|
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
|
||||||
|
self.password_reset_enabled = Some(enabled);
|
||||||
|
}
|
||||||
|
Msg::PasswordResetProbeFinished(Err(err)) => {
|
||||||
|
self.password_reset_enabled = Some(false);
|
||||||
|
error!(&format!(
|
||||||
|
"Could not probe for password reset support: {err:#}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if self.user_info.is_none() {
|
|
||||||
self.route_dispatcher
|
|
||||||
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/login")));
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let link = ctx.link().clone();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
let link = self.link.clone();
|
|
||||||
let is_admin = self.is_admin();
|
let is_admin = self.is_admin();
|
||||||
|
let username = self.user_info.clone().map(|(username, _)| username);
|
||||||
|
let password_reset_enabled = self.password_reset_enabled;
|
||||||
html! {
|
html! {
|
||||||
<div class="container shadow-sm py-3">
|
<div>
|
||||||
{self.view_banner()}
|
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||||
<div class="row justify-content-center">
|
<div class="container py-3 bg-kug">
|
||||||
<div class="shadow-sm py-3" style="max-width: 1000px">
|
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||||
<Router<AppRoute>
|
<main class="py-3" style="max-width: 1000px">
|
||||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
<Switch<AppRoute>
|
||||||
|
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{self.view_footer()}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn get_redirect_route() -> Option<AppRoute> {
|
// Get the page to land on after logging in, defaulting to the index.
|
||||||
let route_service = RouteService::<()>::new();
|
fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
|
||||||
let current_route = route_service.get_path();
|
let route = ctx.link().history().unwrap().location().route::<AppRoute>();
|
||||||
if current_route.is_empty()
|
route.filter(|route| {
|
||||||
|| current_route == "/"
|
!matches!(
|
||||||
|| current_route.contains("login")
|
route,
|
||||||
|| current_route.contains("reset-password")
|
AppRoute::Index
|
||||||
{
|
| AppRoute::Login
|
||||||
None
|
| AppRoute::StartResetPassword
|
||||||
} else {
|
| AppRoute::FinishResetPassword { token: _ }
|
||||||
use yew_router::Switch;
|
)
|
||||||
AppRoute::from_route_part::<()>(current_route, None).0
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_initial_redirections(&mut self) {
|
fn apply_initial_redirections(&self, ctx: &Context<Self>) {
|
||||||
let route_service = RouteService::<()>::new();
|
let history = ctx.link().history().unwrap();
|
||||||
let current_route = route_service.get_path();
|
let route = history.location().route::<AppRoute>();
|
||||||
if current_route.contains("reset-password") {
|
let redirection = match (route, &self.user_info, &self.redirect_to) {
|
||||||
return;
|
(
|
||||||
}
|
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
|
||||||
match &self.user_info {
|
_,
|
||||||
None => {
|
_,
|
||||||
self.route_dispatcher
|
) => {
|
||||||
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/login")));
|
if self.password_reset_enabled == Some(false) {
|
||||||
|
Some(AppRoute::Login)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some((user_name, is_admin)) => match &self.redirect_to {
|
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
|
||||||
Some(url) => {
|
// User is logged in, a URL was given, don't redirect.
|
||||||
self.route_dispatcher
|
(_, Some(_), Some(_)) => None,
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
|
(_, Some((user_name, is_admin)), None) => {
|
||||||
|
if *is_admin {
|
||||||
|
Some(AppRoute::ListUsers)
|
||||||
|
} else {
|
||||||
|
Some(AppRoute::UserDetails {
|
||||||
|
user_id: user_name.clone(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
None => {
|
}
|
||||||
if *is_admin {
|
};
|
||||||
self.route_dispatcher
|
if let Some(redirect_to) = redirection {
|
||||||
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/users")));
|
history.push(redirect_to);
|
||||||
} else {
|
|
||||||
self.route_dispatcher
|
|
||||||
.send(RouteRequest::ReplaceRoute(Route::from(
|
|
||||||
AppRoute::UserDetails(user_name.clone()),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
|
fn dispatch_route(
|
||||||
|
switch: &AppRoute,
|
||||||
|
link: &Scope<Self>,
|
||||||
|
is_admin: bool,
|
||||||
|
password_reset_enabled: Option<bool>,
|
||||||
|
) -> Html {
|
||||||
match switch {
|
match switch {
|
||||||
AppRoute::Login => html! {
|
AppRoute::Login => html! {
|
||||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
<LoginForm on_logged_in={link.callback(Msg::Login)} password_reset_enabled={password_reset_enabled.unwrap_or(false)}/>
|
||||||
},
|
},
|
||||||
AppRoute::CreateUser => html! {
|
AppRoute::CreateUser => html! {
|
||||||
<CreateUserForm/>
|
<CreateUserForm/>
|
||||||
@@ -170,104 +203,84 @@ impl App {
|
|||||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||||
<div>
|
<div>
|
||||||
<UserTable />
|
<UserTable />
|
||||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
|
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
||||||
|
<i class="bi-person-plus me-2"></i>
|
||||||
|
{"Create a user"}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
},
|
},
|
||||||
AppRoute::CreateGroup => html! {
|
AppRoute::CreateGroup => html! {
|
||||||
<CreateGroupForm/>
|
<CreateGroupForm/>
|
||||||
},
|
},
|
||||||
|
AppRoute::CreateUserAttribute => html! {
|
||||||
|
<CreateUserAttributeForm/>
|
||||||
|
},
|
||||||
|
AppRoute::CreateGroupAttribute => html! {
|
||||||
|
<CreateGroupAttributeForm/>
|
||||||
|
},
|
||||||
AppRoute::ListGroups => html! {
|
AppRoute::ListGroups => html! {
|
||||||
<div>
|
<div>
|
||||||
<GroupTable />
|
<GroupTable />
|
||||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
|
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Create a group"}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
},
|
},
|
||||||
AppRoute::GroupDetails(group_id) => html! {
|
AppRoute::ListUserSchema => html! {
|
||||||
<GroupDetails group_id=group_id />
|
<ListUserSchema />
|
||||||
},
|
},
|
||||||
AppRoute::UserDetails(username) => html! {
|
AppRoute::ListGroupSchema => html! {
|
||||||
<UserDetails username=username is_admin=is_admin />
|
<ListGroupSchema />
|
||||||
},
|
},
|
||||||
AppRoute::ChangePassword(username) => html! {
|
AppRoute::GroupDetails { group_id } => html! {
|
||||||
<ChangePasswordForm username=username is_admin=is_admin />
|
<GroupDetails group_id={*group_id} />
|
||||||
},
|
},
|
||||||
AppRoute::StartResetPassword => html! {
|
AppRoute::UserDetails { user_id } => html! {
|
||||||
<ResetPasswordStep1Form />
|
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||||
},
|
},
|
||||||
AppRoute::FinishResetPassword(token) => html! {
|
AppRoute::ChangePassword { user_id } => html! {
|
||||||
<ResetPasswordStep2Form token=token />
|
<ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
|
||||||
|
},
|
||||||
|
AppRoute::StartResetPassword => match password_reset_enabled {
|
||||||
|
Some(true) => html! { <ResetPasswordStep1Form /> },
|
||||||
|
Some(false) => {
|
||||||
|
html! { <Redirect to={AppRoute::Login}/> }
|
||||||
|
}
|
||||||
|
|
||||||
|
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_banner(&self) -> Html {
|
fn view_footer(&self) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<header class="p-3 mb-4 border-bottom shadow-sm">
|
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||||
<div class="container">
|
<div>
|
||||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||||
<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>
|
</div>
|
||||||
</header>
|
<div>
|
||||||
|
<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">
|
||||||
|
<i class="bi-discord"></i>
|
||||||
|
</a>
|
||||||
|
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw" class="me-4 text-reset">
|
||||||
|
<i class="bi-twitter"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{"License "}<a href="https://github.com/lldap/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
app/src/components/avatar.rs
Normal file
87
app/src/components/avatar.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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",
|
||||||
|
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetUserDetails;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub user: AttrValue,
|
||||||
|
#[prop_or(32)]
|
||||||
|
pub width: i32,
|
||||||
|
#[prop_or(32)]
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Avatar)]
|
||||||
|
pub fn avatar(props: &Props) -> Html {
|
||||||
|
let user_details = use_graphql_call::<GetUserDetails>(get_user_details::Variables {
|
||||||
|
id: props.user.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
match &(*user_details) {
|
||||||
|
LoadableResult::Loaded(Ok(response)) => {
|
||||||
|
let avatar = response.user.avatar.clone();
|
||||||
|
match &avatar {
|
||||||
|
Some(data) => html! {
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", data)}
|
||||||
|
style={format!("max-height:{}px;max-width:{}px;height:auto;width:auto;", props.height, props.width)}
|
||||||
|
alt="Avatar" />
|
||||||
|
},
|
||||||
|
None => html! {
|
||||||
|
<BlankAvatarDisplay
|
||||||
|
width={props.width}
|
||||||
|
height={props.height} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoadableResult::Loaded(Err(error)) => html! {
|
||||||
|
<BlankAvatarDisplay
|
||||||
|
error={error.to_string()}
|
||||||
|
width={props.width}
|
||||||
|
height={props.height} />
|
||||||
|
},
|
||||||
|
LoadableResult::Loading => html! {
|
||||||
|
<BlankAvatarDisplay
|
||||||
|
width={props.width}
|
||||||
|
height={props.height} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct BlankAvatarDisplayProps {
|
||||||
|
#[prop_or(None)]
|
||||||
|
pub error: Option<AttrValue>,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(BlankAvatarDisplay)]
|
||||||
|
fn blank_avatar_display(props: &BlankAvatarDisplayProps) -> Html {
|
||||||
|
let fill = match &props.error {
|
||||||
|
Some(_) => "red",
|
||||||
|
None => "currentColor",
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={props.width.to_string()}
|
||||||
|
height={props.height.to_string()}
|
||||||
|
fill={fill}
|
||||||
|
class="bi bi-person-circle"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<title>{props.error.clone().unwrap_or(AttrValue::Static("Avatar"))}</title>
|
||||||
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
|
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/src/components/banner.rs
Normal file
132
app/src/components/banner.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
use crate::components::{
|
||||||
|
avatar::Avatar,
|
||||||
|
logout::LogoutButton,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
};
|
||||||
|
use wasm_bindgen::prelude::wasm_bindgen;
|
||||||
|
use yew::{function_component, html, Callback, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub on_logged_out: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Banner)]
|
||||||
|
pub fn banner(props: &Props) -> Html {
|
||||||
|
html! {
|
||||||
|
<header class="p-2 mb-3 border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||||
|
<a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
||||||
|
<h2>{"LLDAP"}</h2>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||||
|
{if props.is_admin { html! {
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListUsers}>
|
||||||
|
<i class="bi-people me-2"></i>
|
||||||
|
{"Users"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListGroups}>
|
||||||
|
<i class="bi-collection me-2"></i>
|
||||||
|
{"Groups"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListUserSchema}>
|
||||||
|
<i class="bi-list-ul me-2"></i>
|
||||||
|
{"User schema"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListGroupSchema}>
|
||||||
|
<i class="bi-list-ul me-2"></i>
|
||||||
|
{"Group schema"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
} } else { html!{} } }
|
||||||
|
</ul>
|
||||||
|
<UserMenu username={props.username.clone()} on_logged_out={props.on_logged_out.clone()}/>
|
||||||
|
<DarkModeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct UserMenuProps {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub on_logged_out: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(UserMenu)]
|
||||||
|
fn user_menu(props: &UserMenuProps) -> Html {
|
||||||
|
match &props.username {
|
||||||
|
Some(username) => html! {
|
||||||
|
<div class="dropdown text-end">
|
||||||
|
<a href="#"
|
||||||
|
class="d-block nav-link text-decoration-none dropdown-toggle"
|
||||||
|
id="dropdownUser"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
<Avatar user={username.clone()} />
|
||||||
|
<span class="ms-2">
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||||
|
aria-labelledby="dropdownUser1"
|
||||||
|
style="">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="dropdown-item"
|
||||||
|
to={AppRoute::UserDetails{ user_id: username.to_string() }}>
|
||||||
|
{"View details"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<LogoutButton on_logged_out={props.on_logged_out.clone()} />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
},
|
||||||
|
_ => html! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = darkmode)]
|
||||||
|
fn toggleDarkMode(doSave: bool);
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
fn inDarkMode() -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(DarkModeToggle)]
|
||||||
|
fn dark_mode_toggle() -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
|
||||||
|
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,30 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use gloo_console::error;
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::{prelude::*, services::ConsoleService};
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq)]
|
#[derive(PartialEq, Eq, Default)]
|
||||||
enum OpaqueData {
|
enum OpaqueData {
|
||||||
|
#[default]
|
||||||
None,
|
None,
|
||||||
Login(opaque::client::login::ClientLogin),
|
Login(opaque::client::login::ClientLogin),
|
||||||
Registration(opaque::client::registration::ClientRegistration),
|
Registration(opaque::client::registration::ClientRegistration),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OpaqueData {
|
|
||||||
fn default() -> Self {
|
|
||||||
OpaqueData::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpaqueData {
|
impl OpaqueData {
|
||||||
fn take(&mut self) -> Self {
|
fn take(&mut self) -> Self {
|
||||||
std::mem::take(self)
|
std::mem::take(self)
|
||||||
@@ -36,7 +32,7 @@ impl OpaqueData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The fields of the form, with the constraints.
|
/// The fields of the form, with the constraints.
|
||||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct FormModel {
|
pub struct FormModel {
|
||||||
#[validate(custom(
|
#[validate(custom(
|
||||||
function = "empty_or_long",
|
function = "empty_or_long",
|
||||||
@@ -61,10 +57,9 @@ pub struct ChangePasswordForm {
|
|||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: Form<FormModel>,
|
form: Form<FormModel>,
|
||||||
opaque_data: OpaqueData,
|
opaque_data: OpaqueData,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Properties)]
|
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
@@ -80,15 +75,20 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use anyhow::Context;
|
||||||
match msg {
|
match msg {
|
||||||
Msg::FormUpdate => Ok(true),
|
Msg::FormUpdate => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
bail!("Check the form for errors");
|
bail!("Check the form for errors");
|
||||||
}
|
}
|
||||||
if self.common.is_admin {
|
if ctx.props().is_admin {
|
||||||
self.handle_msg(Msg::SubmitNewPassword)
|
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||||
} else {
|
} else {
|
||||||
let old_password = self.form.model().old_password;
|
let old_password = self.form.model().old_password;
|
||||||
if old_password.is_empty() {
|
if old_password.is_empty() {
|
||||||
@@ -100,14 +100,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
|||||||
.context("Could not initialize login")?;
|
.context("Could not initialize login")?;
|
||||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||||
let req = login::ClientLoginStartRequest {
|
let req = login::ClientLoginStartRequest {
|
||||||
username: self.common.username.clone(),
|
username: ctx.props().username.clone().into(),
|
||||||
login_start_request: login_start_request.message,
|
login_start_request: login_start_request.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::login_start,
|
ctx,
|
||||||
req,
|
HostService::login_start(req),
|
||||||
Msg::AuthenticationStartResponse,
|
Msg::AuthenticationStartResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,34 +119,33 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
|||||||
|e| {
|
|e| {
|
||||||
// Common error, we want to print a full error to the console but only a
|
// Common error, we want to print a full error to the console but only a
|
||||||
// simple one to the user.
|
// simple one to the user.
|
||||||
ConsoleService::error(&format!(
|
error!(&format!("Invalid username or password: {}", e));
|
||||||
"Invalid username or password: {}",
|
|
||||||
e
|
|
||||||
));
|
|
||||||
anyhow!("Invalid username or password")
|
anyhow!("Invalid username or password")
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
_ => panic!("Unexpected data in opaque_data field"),
|
_ => panic!("Unexpected data in opaque_data field"),
|
||||||
};
|
};
|
||||||
self.handle_msg(Msg::SubmitNewPassword)
|
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||||
}
|
}
|
||||||
Msg::SubmitNewPassword => {
|
Msg::SubmitNewPassword => {
|
||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
let new_password = self.form.model().password;
|
let new_password = self.form.model().password;
|
||||||
let registration_start_request =
|
let registration_start_request = opaque::client::registration::start_registration(
|
||||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
new_password.as_bytes(),
|
||||||
.context("Could not initiate password change")?;
|
&mut rng,
|
||||||
|
)
|
||||||
|
.context("Could not initiate password change")?;
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: self.common.username.clone(),
|
username: ctx.props().username.clone().into(),
|
||||||
registration_start_request: registration_start_request.message,
|
registration_start_request: registration_start_request.message,
|
||||||
};
|
};
|
||||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_start,
|
ctx,
|
||||||
req,
|
HostService::register_start(req),
|
||||||
Msg::RegistrationStartResponse,
|
Msg::RegistrationStartResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::RegistrationStartResponse(res) => {
|
Msg::RegistrationStartResponse(res) => {
|
||||||
@@ -166,22 +165,20 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
|||||||
registration_upload: registration_finish.message,
|
registration_upload: registration_finish.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_finish,
|
ctx,
|
||||||
req,
|
HostService::register_finish(req),
|
||||||
Msg::RegistrationFinishResponse,
|
Msg::RegistrationFinishResponse,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
_ => panic!("Unexpected data in opaque_data field"),
|
_ => panic!("Unexpected data in opaque_data field"),
|
||||||
}?;
|
};
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
if response.is_ok() {
|
if response.is_ok() {
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::UserDetails {
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(
|
user_id: ctx.props().username.clone(),
|
||||||
AppRoute::UserDetails(self.common.username.clone()),
|
});
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
response?;
|
response?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -198,114 +195,76 @@ impl Component for ChangePasswordForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
ChangePasswordForm {
|
ChangePasswordForm {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||||
opaque_data: OpaqueData::None,
|
opaque_data: OpaqueData::None,
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let is_admin = ctx.props().is_admin;
|
||||||
}
|
let link = ctx.link();
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
let is_admin = self.common.is_admin;
|
|
||||||
type Field = yew_form::Field<FormModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<form
|
<div class="mb-2 mt-2">
|
||||||
class="form">
|
<h5 class="fw-bold">
|
||||||
{if !is_admin { html! {
|
{"Change password"}
|
||||||
<div class="form-group row">
|
</h5>
|
||||||
<label for="old_password"
|
</div>
|
||||||
class="form-label col-sm-2 col-form-label">
|
{
|
||||||
{"Current password*:"}
|
if let Some(e) = &self.common.error {
|
||||||
</label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
field_name="old_password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="current-password"
|
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("old_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}} else { html! {} }}
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="new_password"
|
|
||||||
class="form-label col-sm-2 col-form-label">
|
|
||||||
{"New password*:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
field_name="password"
|
|
||||||
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 {
|
|
||||||
html! {
|
html! {
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger mt-3 mb-3">
|
||||||
{e.to_string() }
|
{e.to_string() }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else { html! {} }
|
} else { html! {} }
|
||||||
}
|
}
|
||||||
<div>
|
<form class="form">
|
||||||
<NavButton
|
{if !is_admin { html! {
|
||||||
classes="btn btn-primary"
|
<Field<FormModel>
|
||||||
route=AppRoute::UserDetails(self.common.username.clone())>
|
form={&self.form}
|
||||||
{"Back"}
|
required=true
|
||||||
</NavButton>
|
label="Current password"
|
||||||
</div>
|
field_name="old_password"
|
||||||
|
input_type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
|
}} else { html! {} }}
|
||||||
|
<Field<FormModel>
|
||||||
|
form={&self.form}
|
||||||
|
required=true
|
||||||
|
label="New password"
|
||||||
|
field_name="password"
|
||||||
|
input_type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
|
<Field<FormModel>
|
||||||
|
form={&self.form}
|
||||||
|
required=true
|
||||||
|
label="Confirm password"
|
||||||
|
field_name="confirm_password"
|
||||||
|
input_type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
|
<Submit
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}
|
||||||
|
text="Save changes" >
|
||||||
|
<Link
|
||||||
|
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
||||||
|
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
||||||
|
<i class="bi-arrow-return-left me-2"></i>
|
||||||
|
{"Back"}
|
||||||
|
</Link>
|
||||||
|
</Submit>
|
||||||
|
</form>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::AppRoute,
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::ConsoleService;
|
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -24,11 +24,10 @@ pub struct CreateGroup;
|
|||||||
|
|
||||||
pub struct CreateGroupForm {
|
pub struct CreateGroupForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
form: yew_form::Form<CreateGroupModel>,
|
form: yew_form::Form<CreateGroupModel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct CreateGroupModel {
|
pub struct CreateGroupModel {
|
||||||
#[validate(length(min = 1, message = "Groupname is required"))]
|
#[validate(length(min = 1, message = "Groupname is required"))]
|
||||||
groupname: String,
|
groupname: String,
|
||||||
@@ -41,7 +40,11 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
@@ -53,6 +56,7 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
|||||||
name: model.groupname,
|
name: model.groupname,
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateGroup, _>(
|
self.common.call_graphql::<CreateGroup, _>(
|
||||||
|
ctx,
|
||||||
req,
|
req,
|
||||||
Msg::CreateGroupResponse,
|
Msg::CreateGroupResponse,
|
||||||
"Error trying to create group",
|
"Error trying to create group",
|
||||||
@@ -60,12 +64,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::CreateGroupResponse(response) => {
|
Msg::CreateGroupResponse(response) => {
|
||||||
ConsoleService::log(&format!(
|
log!(&format!(
|
||||||
"Created group '{}'",
|
"Created group '{}'",
|
||||||
&response?.create_group.display_name
|
&response?.create_group.display_name
|
||||||
));
|
));
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,58 +83,34 @@ impl Component for CreateGroupForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let link = ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<CreateGroupModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form shadow-sm py-3" style="max-width: 636px">
|
<form class="form py-3" style="max-width: 636px">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group row mb-3">
|
<Field<CreateGroupModel>
|
||||||
<label for="groupname"
|
form={&self.form}
|
||||||
class="form-label col-4 col-form-label">
|
required=true
|
||||||
{"Group name*:"}
|
label="Group name"
|
||||||
</label>
|
field_name="groupname"
|
||||||
<div class="col-8">
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<Field
|
<Submit
|
||||||
form=&self.form
|
disabled={self.common.is_task_running()}
|
||||||
field_name="groupname"
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||||
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>
|
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
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,21 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::AppRoute,
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{opaque, registration};
|
use lldap_auth::{opaque, registration};
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew::services::ConsoleService;
|
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
@@ -28,17 +28,15 @@ pub struct CreateUser;
|
|||||||
|
|
||||||
pub struct CreateUserForm {
|
pub struct CreateUserForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
form: yew_form::Form<CreateUserModel>,
|
form: yew_form::Form<CreateUserModel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct CreateUserModel {
|
pub struct CreateUserModel {
|
||||||
#[validate(length(min = 1, message = "Username is required"))]
|
#[validate(length(min = 1, message = "Username is required"))]
|
||||||
username: String,
|
username: String,
|
||||||
#[validate(email(message = "A valid email is required"))]
|
#[validate(email(message = "A valid email is required"))]
|
||||||
email: String,
|
email: String,
|
||||||
#[validate(length(min = 1, message = "Display name is required"))]
|
|
||||||
display_name: String,
|
display_name: String,
|
||||||
first_name: String,
|
first_name: String,
|
||||||
last_name: String,
|
last_name: String,
|
||||||
@@ -74,7 +72,11 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
@@ -90,9 +92,12 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
displayName: to_option(model.display_name),
|
displayName: to_option(model.display_name),
|
||||||
firstName: to_option(model.first_name),
|
firstName: to_option(model.first_name),
|
||||||
lastName: to_option(model.last_name),
|
lastName: to_option(model.last_name),
|
||||||
|
avatar: None,
|
||||||
|
attributes: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateUser, _>(
|
self.common.call_graphql::<CreateUser, _>(
|
||||||
|
ctx,
|
||||||
req,
|
req,
|
||||||
Msg::CreateUserResponse,
|
Msg::CreateUserResponse,
|
||||||
"Error trying to create user",
|
"Error trying to create user",
|
||||||
@@ -102,7 +107,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
Msg::CreateUserResponse(r) => {
|
Msg::CreateUserResponse(r) => {
|
||||||
match r {
|
match r {
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
Ok(r) => ConsoleService::log(&format!(
|
Ok(r) => log!(&format!(
|
||||||
"Created user '{}' at '{}'",
|
"Created user '{}' at '{}'",
|
||||||
&r.create_user.id, &r.create_user.creation_date
|
&r.create_user.id, &r.create_user.creation_date
|
||||||
)),
|
)),
|
||||||
@@ -116,18 +121,20 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
let opaque::client::registration::ClientRegistrationStartResult {
|
let opaque::client::registration::ClientRegistrationStartResult {
|
||||||
state,
|
state,
|
||||||
message,
|
message,
|
||||||
} = opaque::client::registration::start_registration(&password, &mut rng)?;
|
} = opaque::client::registration::start_registration(
|
||||||
|
password.as_bytes(),
|
||||||
|
&mut rng,
|
||||||
|
)?;
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: user_id,
|
username: user_id.into(),
|
||||||
registration_start_request: message,
|
registration_start_request: message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common
|
||||||
.call_backend(HostService::register_start, req, move |r| {
|
.call_backend(ctx, HostService::register_start(req), move |r| {
|
||||||
Msg::RegistrationStartResponse((state, r))
|
Msg::RegistrationStartResponse((state, r))
|
||||||
})
|
});
|
||||||
.context("Error trying to create user")?;
|
|
||||||
} else {
|
} else {
|
||||||
self.update(Msg::SuccessfulCreation);
|
self.update(ctx, Msg::SuccessfulCreation);
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
@@ -143,22 +150,19 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
server_data: response.server_data,
|
server_data: response.server_data,
|
||||||
registration_upload: registration_upload.message,
|
registration_upload: registration_upload.message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common.call_backend(
|
||||||
.call_backend(
|
ctx,
|
||||||
HostService::register_finish,
|
HostService::register_finish(req),
|
||||||
req,
|
Msg::RegistrationFinishResponse,
|
||||||
Msg::RegistrationFinishResponse,
|
);
|
||||||
)
|
|
||||||
.context("Error trying to register user")?;
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.handle_msg(Msg::SuccessfulCreation)
|
self.handle_msg(ctx, Msg::SuccessfulCreation)
|
||||||
}
|
}
|
||||||
Msg::SuccessfulCreation => {
|
Msg::SuccessfulCreation => {
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::ListUsers);
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListUsers)));
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,177 +177,73 @@ impl Component for CreateUserForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<CreateUserModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form shadow-sm py-3" style="max-width: 636px">
|
<form class="form py-3" style="max-width: 636px">
|
||||||
<div class="row mb-3">
|
<Field<CreateUserModel>
|
||||||
<h5 class="fw-bold">{"Create a user"}</h5>
|
form={&self.form}
|
||||||
</div>
|
required=true
|
||||||
<div class="form-group row mb-3">
|
label="User name"
|
||||||
<label for="username"
|
field_name="username"
|
||||||
class="form-label col-4 col-form-label">
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
{"User name*:"}
|
<Field<CreateUserModel>
|
||||||
</label>
|
form={&self.form}
|
||||||
<div class="col-8">
|
required=true
|
||||||
<Field
|
label="Email"
|
||||||
form=&self.form
|
field_name="email"
|
||||||
field_name="username"
|
input_type="email"
|
||||||
class="form-control"
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
class_invalid="is-invalid has-error"
|
<Field<CreateUserModel>
|
||||||
class_valid="has-success"
|
form={&self.form}
|
||||||
autocomplete="username"
|
label="Display name"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
field_name="display_name"
|
||||||
<div class="invalid-feedback">
|
autocomplete="name"
|
||||||
{&self.form.field_message("username")}
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</div>
|
<Field<CreateUserModel>
|
||||||
</div>
|
form={&self.form}
|
||||||
</div>
|
label="First name"
|
||||||
<div class="form-group row mb-3">
|
field_name="first_name"
|
||||||
<label for="email"
|
autocomplete="given-name"
|
||||||
class="form-label col-4 col-form-label">
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
{"Email*:"}
|
<Field<CreateUserModel>
|
||||||
</label>
|
form={&self.form}
|
||||||
<div class="col-8">
|
label="Last name"
|
||||||
<Field
|
field_name="last_name"
|
||||||
form=&self.form
|
autocomplete="family-name"
|
||||||
input_type="email"
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
field_name="email"
|
<Field<CreateUserModel>
|
||||||
class="form-control"
|
form={&self.form}
|
||||||
class_invalid="is-invalid has-error"
|
label="Password"
|
||||||
class_valid="has-success"
|
field_name="password"
|
||||||
autocomplete="email"
|
input_type="password"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
autocomplete="new-password"
|
||||||
<div class="invalid-feedback">
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
{&self.form.field_message("email")}
|
<Field<CreateUserModel>
|
||||||
</div>
|
form={&self.form}
|
||||||
</div>
|
label="Confirm password"
|
||||||
</div>
|
field_name="confirm_password"
|
||||||
<div class="form-group row mb-3">
|
input_type="password"
|
||||||
<label for="display-name"
|
autocomplete="new-password"
|
||||||
class="form-label col-4 col-form-label">
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
{"Display name*:"}
|
<Submit
|
||||||
</label>
|
disabled={self.common.is_task_running()}
|
||||||
<div class="col-8">
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
autocomplete="name"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
field_name="display_name"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("display_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="first-name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"First name:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
autocomplete="given-name"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
field_name="first_name"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("first_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="last-name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Last name:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
autocomplete="family-name"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
field_name="last_name"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("last_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="password"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Password:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
input_type="password"
|
|
||||||
field_name="password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="confirm_password"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Confirm password:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
input_type="password"
|
|
||||||
field_name="confirm_password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("confirm_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row justify-content-center">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary col-auto col-form-label mt-4"
|
|
||||||
disabled=self.common.is_task_running()
|
|
||||||
type="submit"
|
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
|
||||||
{"Submit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.common.error {
|
{
|
||||||
|
if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
{e.to_string() }
|
{e.to_string() }
|
||||||
|
|||||||
175
app/src/components/create_user_attribute.rs
Normal file
175
app/src/components/create_user_attribute.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::{validate_attribute_type, AttributeType},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator_derive::Validate;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_form_derive::Model;
|
||||||
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/create_user_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct CreateUserAttribute;
|
||||||
|
|
||||||
|
convert_attribute_type!(create_user_attribute::AttributeType);
|
||||||
|
|
||||||
|
pub struct CreateUserAttributeForm {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
form: yew_form::Form<CreateUserAttributeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||||
|
pub struct CreateUserAttributeModel {
|
||||||
|
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||||
|
attribute_name: String,
|
||||||
|
#[validate(custom = "validate_attribute_type")]
|
||||||
|
attribute_type: String,
|
||||||
|
is_editable: bool,
|
||||||
|
is_list: bool,
|
||||||
|
is_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
Update,
|
||||||
|
SubmitForm,
|
||||||
|
CreateUserAttributeResponse(Result<create_user_attribute::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => Ok(true),
|
||||||
|
Msg::SubmitForm => {
|
||||||
|
if !self.form.validate() {
|
||||||
|
bail!("Check the form for errors");
|
||||||
|
}
|
||||||
|
let model = self.form.model();
|
||||||
|
if model.is_editable && !model.is_visible {
|
||||||
|
bail!("Editable attributes must also be visible");
|
||||||
|
}
|
||||||
|
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||||
|
let req = create_user_attribute::Variables {
|
||||||
|
name: model.attribute_name,
|
||||||
|
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
|
||||||
|
is_editable: model.is_editable,
|
||||||
|
is_list: model.is_list,
|
||||||
|
is_visible: model.is_visible,
|
||||||
|
};
|
||||||
|
self.common.call_graphql::<CreateUserAttribute, _>(
|
||||||
|
ctx,
|
||||||
|
req,
|
||||||
|
Msg::CreateUserAttributeResponse,
|
||||||
|
"Error trying to create user attribute",
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::CreateUserAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
let model = self.form.model();
|
||||||
|
log!(&format!(
|
||||||
|
"Created user attribute '{}'",
|
||||||
|
model.attribute_name
|
||||||
|
));
|
||||||
|
ctx.link().history().unwrap().push(AppRoute::ListUserSchema);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for CreateUserAttributeForm {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = ();
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
let model = CreateUserAttributeModel {
|
||||||
|
attribute_type: AttributeType::String.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
form: yew_form::Form::<CreateUserAttributeModel>::new(model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
html! {
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<form class="form py-3" style="max-width: 636px">
|
||||||
|
<h5 class="fw-bold">{"Create a user attribute"}</h5>
|
||||||
|
<Field<CreateUserAttributeModel>
|
||||||
|
label="Name"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Select<CreateUserAttributeModel>
|
||||||
|
label="Type"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_type"
|
||||||
|
oninput={link.callback(|_| Msg::Update)}>
|
||||||
|
<option selected=true value="String">{"String"}</option>
|
||||||
|
<option value="Integer">{"Integer"}</option>
|
||||||
|
<option value="Jpeg">{"Jpeg"}</option>
|
||||||
|
<option value="DateTime">{"DateTime"}</option>
|
||||||
|
</Select<CreateUserAttributeModel>>
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Multiple values"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_list"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Visible to users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_visible"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Editable by users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_editable"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<Submit
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||||
|
</form>
|
||||||
|
{ if let Some(e) = &self.common.error {
|
||||||
|
html! {
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,16 +39,21 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ClickedDeleteGroup => {
|
Msg::ClickedDeleteGroup => {
|
||||||
self.modal.as_ref().expect("modal not initialized").show();
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
}
|
}
|
||||||
Msg::ConfirmDeleteGroup => {
|
Msg::ConfirmDeleteGroup => {
|
||||||
self.update(Msg::DismissModal);
|
self.update(ctx, Msg::DismissModal);
|
||||||
self.common.call_graphql::<DeleteGroupQuery, _>(
|
self.common.call_graphql::<DeleteGroupQuery, _>(
|
||||||
|
ctx,
|
||||||
delete_group_query::Variables {
|
delete_group_query::Variables {
|
||||||
group_id: self.common.group.id,
|
group_id: ctx.props().group.id,
|
||||||
},
|
},
|
||||||
Msg::DeleteGroupResponse,
|
Msg::DeleteGroupResponse,
|
||||||
"Error trying to delete group",
|
"Error trying to delete group",
|
||||||
@@ -58,12 +63,8 @@ impl CommonComponent<DeleteGroup> for DeleteGroup {
|
|||||||
self.modal.as_ref().expect("modal not initialized").hide();
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
}
|
}
|
||||||
Msg::DeleteGroupResponse(response) => {
|
Msg::DeleteGroupResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
response?;
|
response?;
|
||||||
self.common
|
ctx.props().on_group_deleted.emit(ctx.props().group.id);
|
||||||
.props
|
|
||||||
.on_group_deleted
|
|
||||||
.emit(self.common.group.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -78,15 +79,15 @@ impl Component for DeleteGroup {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = DeleteGroupProps;
|
type Properties = DeleteGroupProps;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
modal: None,
|
modal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered(&mut self, first_render: bool) {
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
if first_render {
|
if first_render {
|
||||||
self.modal = Some(Modal::new(
|
self.modal = Some(Modal::new(
|
||||||
self.node_ref
|
self.node_ref
|
||||||
@@ -96,43 +97,42 @@ impl Component for DeleteGroup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
onclick={link.callback(|_| Msg::ClickedDeleteGroup)}>
|
||||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||||
</button>
|
</button>
|
||||||
{self.show_modal()}
|
{self.show_modal(ctx)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeleteGroup {
|
impl DeleteGroup {
|
||||||
fn show_modal(&self) -> Html {
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
class="modal fade"
|
||||||
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
|
id={"deleteGroupModal".to_string() + &ctx.props().group.id.to_string()}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-labelledby="deleteGroupModalLabel"
|
aria-labelledby="deleteGroupModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
ref=self.node_ref.clone()>
|
ref={self.node_ref.clone()}>
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -141,25 +141,29 @@ impl DeleteGroup {
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span>
|
<span>
|
||||||
{"Are you sure you want to delete group "}
|
{"Are you sure you want to delete group "}
|
||||||
<b>{&self.common.group.display_name}</b>{"?"}
|
<b>{&ctx.props().group.display_name}</b>{"?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
|
<i class="bi-x-circle me-2"></i>
|
||||||
{"Cancel"}
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
onclick={link.callback(|_| Msg::ConfirmDeleteGroup)}
|
||||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
172
app/src/components/delete_group_attribute.rs
Normal file
172
app/src/components/delete_group_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
modal::Modal,
|
||||||
|
};
|
||||||
|
use anyhow::{Error, Result};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/delete_group_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct DeleteGroupAttributeQuery;
|
||||||
|
|
||||||
|
pub struct DeleteGroupAttribute {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
node_ref: NodeRef,
|
||||||
|
modal: Option<Modal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||||
|
pub struct DeleteGroupAttributeProps {
|
||||||
|
pub attribute_name: String,
|
||||||
|
pub on_attribute_deleted: Callback<String>,
|
||||||
|
pub on_error: Callback<Error>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ClickedDeleteGroupAttribute,
|
||||||
|
ConfirmDeleteGroupAttribute,
|
||||||
|
DismissModal,
|
||||||
|
DeleteGroupAttributeResponse(Result<delete_group_attribute_query::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<DeleteGroupAttribute> for DeleteGroupAttribute {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ClickedDeleteGroupAttribute => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
|
}
|
||||||
|
Msg::ConfirmDeleteGroupAttribute => {
|
||||||
|
self.update(ctx, Msg::DismissModal);
|
||||||
|
self.common.call_graphql::<DeleteGroupAttributeQuery, _>(
|
||||||
|
ctx,
|
||||||
|
delete_group_attribute_query::Variables {
|
||||||
|
name: ctx.props().attribute_name.clone(),
|
||||||
|
},
|
||||||
|
Msg::DeleteGroupAttributeResponse,
|
||||||
|
"Error trying to delete group attribute",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Msg::DismissModal => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
|
}
|
||||||
|
Msg::DeleteGroupAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
ctx.props()
|
||||||
|
.on_attribute_deleted
|
||||||
|
.emit(ctx.props().attribute_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for DeleteGroupAttribute {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = DeleteGroupAttributeProps;
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
node_ref: NodeRef::default(),
|
||||||
|
modal: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
|
if first_render {
|
||||||
|
self.modal = Some(Modal::new(
|
||||||
|
self.node_ref
|
||||||
|
.cast::<web_sys::Element>()
|
||||||
|
.expect("Modal node is not an element"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
|
self,
|
||||||
|
ctx,
|
||||||
|
msg,
|
||||||
|
ctx.props().on_error.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|_| Msg::ClickedDeleteGroupAttribute)}>
|
||||||
|
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||||
|
</button>
|
||||||
|
{self.show_modal(ctx)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteGroupAttribute {
|
||||||
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="modal fade"
|
||||||
|
id={"deleteGroupAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="deleteGroupAttributeModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
ref={self.node_ref.clone()}>
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteGroupAttributeModalLabel">{"Delete group attribute?"}</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Close"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<span>
|
||||||
|
{"Are you sure you want to delete group attribute "}
|
||||||
|
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
|
<i class="bi-x-circle me-2"></i>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| Msg::ConfirmDeleteGroupAttribute)}
|
||||||
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,16 +36,21 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<DeleteUser> for DeleteUser {
|
impl CommonComponent<DeleteUser> for DeleteUser {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ClickedDeleteUser => {
|
Msg::ClickedDeleteUser => {
|
||||||
self.modal.as_ref().expect("modal not initialized").show();
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
}
|
}
|
||||||
Msg::ConfirmDeleteUser => {
|
Msg::ConfirmDeleteUser => {
|
||||||
self.update(Msg::DismissModal);
|
self.update(ctx, Msg::DismissModal);
|
||||||
self.common.call_graphql::<DeleteUserQuery, _>(
|
self.common.call_graphql::<DeleteUserQuery, _>(
|
||||||
|
ctx,
|
||||||
delete_user_query::Variables {
|
delete_user_query::Variables {
|
||||||
user: self.common.username.clone(),
|
user: ctx.props().username.clone(),
|
||||||
},
|
},
|
||||||
Msg::DeleteUserResponse,
|
Msg::DeleteUserResponse,
|
||||||
"Error trying to delete user",
|
"Error trying to delete user",
|
||||||
@@ -55,12 +60,10 @@ impl CommonComponent<DeleteUser> for DeleteUser {
|
|||||||
self.modal.as_ref().expect("modal not initialized").hide();
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
}
|
}
|
||||||
Msg::DeleteUserResponse(response) => {
|
Msg::DeleteUserResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
response?;
|
response?;
|
||||||
self.common
|
ctx.props()
|
||||||
.props
|
|
||||||
.on_user_deleted
|
.on_user_deleted
|
||||||
.emit(self.common.username.clone());
|
.emit(ctx.props().username.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -75,15 +78,15 @@ impl Component for DeleteUser {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = DeleteUserProps;
|
type Properties = DeleteUserProps;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
modal: None,
|
modal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered(&mut self, first_render: bool) {
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
if first_render {
|
if first_render {
|
||||||
self.modal = Some(Modal::new(
|
self.modal = Some(Modal::new(
|
||||||
self.node_ref
|
self.node_ref
|
||||||
@@ -93,44 +96,43 @@ impl Component for DeleteUser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.common.change(props)
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
onclick={link.callback(|_| Msg::ClickedDeleteUser)}>
|
||||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||||
</button>
|
</button>
|
||||||
{self.show_modal()}
|
{self.show_modal(ctx)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeleteUser {
|
impl DeleteUser {
|
||||||
fn show_modal(&self) -> Html {
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="modal fade"
|
class="modal fade"
|
||||||
id="deleteUserModal".to_string() + &self.common.username
|
id={"deleteUserModal".to_string() + &ctx.props().username}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
//role="dialog"
|
//role="dialog"
|
||||||
aria-labelledby="deleteUserModalLabel"
|
aria-labelledby="deleteUserModalLabel"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
ref=self.node_ref.clone()>
|
ref={self.node_ref.clone()}>
|
||||||
<div class="modal-dialog" /*role="document"*/>
|
<div class="modal-dialog" /*role="document"*/>
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -139,25 +141,29 @@ impl DeleteUser {
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn-close"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<span>
|
<span>
|
||||||
{"Are you sure you want to delete user "}
|
{"Are you sure you want to delete user "}
|
||||||
<b>{&self.common.username}</b>{"?"}
|
<b>{&ctx.props().username}</b>{"?"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
{"Cancel"}
|
<i class="bi-x-circle me-2"></i>
|
||||||
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
onclick={link.callback(|_| Msg::ConfirmDeleteUser)}
|
||||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
172
app/src/components/delete_user_attribute.rs
Normal file
172
app/src/components/delete_user_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
modal::Modal,
|
||||||
|
};
|
||||||
|
use anyhow::{Error, Result};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/delete_user_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct DeleteUserAttributeQuery;
|
||||||
|
|
||||||
|
pub struct DeleteUserAttribute {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
node_ref: NodeRef,
|
||||||
|
modal: Option<Modal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||||
|
pub struct DeleteUserAttributeProps {
|
||||||
|
pub attribute_name: String,
|
||||||
|
pub on_attribute_deleted: Callback<String>,
|
||||||
|
pub on_error: Callback<Error>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ClickedDeleteUserAttribute,
|
||||||
|
ConfirmDeleteUserAttribute,
|
||||||
|
DismissModal,
|
||||||
|
DeleteUserAttributeResponse(Result<delete_user_attribute_query::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<DeleteUserAttribute> for DeleteUserAttribute {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ClickedDeleteUserAttribute => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
|
}
|
||||||
|
Msg::ConfirmDeleteUserAttribute => {
|
||||||
|
self.update(ctx, Msg::DismissModal);
|
||||||
|
self.common.call_graphql::<DeleteUserAttributeQuery, _>(
|
||||||
|
ctx,
|
||||||
|
delete_user_attribute_query::Variables {
|
||||||
|
name: ctx.props().attribute_name.clone(),
|
||||||
|
},
|
||||||
|
Msg::DeleteUserAttributeResponse,
|
||||||
|
"Error trying to delete user attribute",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Msg::DismissModal => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
|
}
|
||||||
|
Msg::DeleteUserAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
ctx.props()
|
||||||
|
.on_attribute_deleted
|
||||||
|
.emit(ctx.props().attribute_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for DeleteUserAttribute {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = DeleteUserAttributeProps;
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
node_ref: NodeRef::default(),
|
||||||
|
modal: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
|
if first_render {
|
||||||
|
self.modal = Some(Modal::new(
|
||||||
|
self.node_ref
|
||||||
|
.cast::<web_sys::Element>()
|
||||||
|
.expect("Modal node is not an element"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
|
self,
|
||||||
|
ctx,
|
||||||
|
msg,
|
||||||
|
ctx.props().on_error.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|_| Msg::ClickedDeleteUserAttribute)}>
|
||||||
|
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||||
|
</button>
|
||||||
|
{self.show_modal(ctx)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteUserAttribute {
|
||||||
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="modal fade"
|
||||||
|
id={"deleteUserAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="deleteUserAttributeModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
ref={self.node_ref.clone()}>
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteUserAttributeModalLabel">{"Delete user attribute?"}</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Close"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<span>
|
||||||
|
{"Are you sure you want to delete user attribute "}
|
||||||
|
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
|
<i class="bi-x-circle me-2"></i>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| Msg::ConfirmDeleteUserAttribute)}
|
||||||
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/src/components/form/attribute_input.rs
Normal file
70
app/src/components/form/attribute_input.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use crate::infra::schema::AttributeType;
|
||||||
|
use yew::{
|
||||||
|
function_component, html, virtual_dom::AttrValue, Callback, InputEvent, NodeRef, Properties,
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
<input
|
||||||
|
ref={&ctx.props().input_ref}
|
||||||
|
type="text"
|
||||||
|
class="input-component"
|
||||||
|
placeholder={placeholder}
|
||||||
|
onmouseover={ctx.link().callback(|_| Msg::Hover)}
|
||||||
|
/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[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 => "datetime-local",
|
||||||
|
AttributeType::Jpeg => "file",
|
||||||
|
};
|
||||||
|
let accept = match props.attribute_type {
|
||||||
|
AttributeType::Jpeg => Some("image/jpeg"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<input
|
||||||
|
type={input_type}
|
||||||
|
accept={accept}
|
||||||
|
name={props.name.clone()}
|
||||||
|
class="form-control"
|
||||||
|
value={props.value.clone()} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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">
|
||||||
|
<label for={props.name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.name}{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<AttributeInput
|
||||||
|
attribute_type={props.attribute_type.clone()}
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={props.value.clone()} />
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/src/components/form/mod.rs
Normal file
6
app/src/components/form/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod attribute_input;
|
||||||
|
pub mod checkbox;
|
||||||
|
pub mod field;
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,16 +40,17 @@ pub enum Msg {
|
|||||||
OnUserRemovedFromGroup((String, i64)),
|
OnUserRemovedFromGroup((String, i64)),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub group_id: i64,
|
pub group_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GroupDetails {
|
impl GroupDetails {
|
||||||
fn get_group_details(&mut self) {
|
fn get_group_details(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<GetGroupDetails, _>(
|
self.common.call_graphql::<GetGroupDetails, _>(
|
||||||
|
ctx,
|
||||||
get_group_details::Variables {
|
get_group_details::Variables {
|
||||||
id: self.common.group_id,
|
id: ctx.props().group_id,
|
||||||
},
|
},
|
||||||
Msg::GroupDetailsResponse,
|
Msg::GroupDetailsResponse,
|
||||||
"Error trying to fetch group details",
|
"Error trying to fetch group details",
|
||||||
@@ -68,34 +69,73 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_user_list(&self, g: &Group) -> Html {
|
fn view_details(&self, g: &Group) -> Html {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<h3>{g.display_name.to_string()}</h3>
|
||||||
|
<div class="py-3">
|
||||||
|
<form class="form">
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for="displayName"
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{"Group: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for="creationDate"
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{"Creation date: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for="uuid"
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{"UUID: "}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_user_list(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
let make_user_row = |user: &User| {
|
let make_user_row = |user: &User| {
|
||||||
let user_id = user.id.clone();
|
let user_id = user.id.clone();
|
||||||
let display_name = user.display_name.clone();
|
let display_name = user.display_name.clone();
|
||||||
html! {
|
html! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::UserDetails(user_id.clone())>
|
<Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
|
||||||
{user_id.clone()}
|
{user_id.clone()}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>{display_name}</td>
|
<td>{display_name}</td>
|
||||||
<td>
|
<td>
|
||||||
<RemoveUserFromGroupComponent
|
<RemoveUserFromGroupComponent
|
||||||
username=user_id
|
username={user_id}
|
||||||
group_id=g.id
|
group_id={g.id}
|
||||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{g.display_name.to_string()}</h3>
|
|
||||||
<h5 class="fw-bold">{"Members"}</h5>
|
<h5 class="fw-bold">{"Members"}</h5>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr key="headerRow">
|
<tr key="headerRow">
|
||||||
<th>{"User Id"}</th>
|
<th>{"User Id"}</th>
|
||||||
@@ -107,7 +147,7 @@ impl GroupDetails {
|
|||||||
{if g.users.is_empty() {
|
{if g.users.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
<tr key="EmptyRow">
|
<tr key="EmptyRow">
|
||||||
<td>{"No members"}</td>
|
<td>{"There are no users in this group."}</td>
|
||||||
<td/>
|
<td/>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -121,7 +161,8 @@ impl GroupDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_add_user_button(&self, g: &Group) -> Html {
|
fn view_add_user_button(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
let users: Vec<_> = g
|
let users: Vec<_> = g
|
||||||
.users
|
.users
|
||||||
.iter()
|
.iter()
|
||||||
@@ -132,16 +173,16 @@ impl GroupDetails {
|
|||||||
.collect();
|
.collect();
|
||||||
html! {
|
html! {
|
||||||
<AddGroupMemberComponent
|
<AddGroupMemberComponent
|
||||||
group_id=g.id
|
group_id={g.id}
|
||||||
users=users
|
users={users}
|
||||||
on_error=self.common.callback(Msg::OnError)
|
on_error={link.callback(Msg::OnError)}
|
||||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::GroupDetailsResponse(response) => match response {
|
Msg::GroupDetailsResponse(response) => match response {
|
||||||
Ok(group) => self.group = Some(group.group),
|
Ok(group) => self.group = Some(group.group),
|
||||||
@@ -177,32 +218,29 @@ impl Component for GroupDetails {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
group: None,
|
group: None,
|
||||||
};
|
};
|
||||||
table.get_group_details();
|
table.get_group_details(ctx);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
match (&self.group, &self.common.error) {
|
match (&self.group, &self.common.error) {
|
||||||
(None, None) => html! {{"Loading..."}},
|
(None, None) => html! {{"Loading..."}},
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
(Some(u), error) => {
|
(Some(u), error) => {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_user_list(u)}
|
{self.view_details(u)}
|
||||||
{self.view_add_user_button(u)}
|
{self.view_user_list(ctx, u)}
|
||||||
|
{self.view_add_user_button(ctx, u)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
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(
|
#[graphql(
|
||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_group_list.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"
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
)]
|
)]
|
||||||
pub struct GetGroupList;
|
pub struct GetGroupList;
|
||||||
@@ -34,7 +34,7 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<GroupTable> for GroupTable {
|
impl CommonComponent<GroupTable> for GroupTable {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ListGroupsResponse(groups) => {
|
Msg::ListGroupsResponse(groups) => {
|
||||||
self.groups = Some(groups?.groups.into_iter().collect());
|
self.groups = Some(groups?.groups.into_iter().collect());
|
||||||
@@ -58,12 +58,13 @@ impl Component for GroupTable {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = GroupTable {
|
let mut table = GroupTable {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
groups: None,
|
groups: None,
|
||||||
};
|
};
|
||||||
table.common.call_graphql::<GetGroupList, _>(
|
table.common.call_graphql::<GetGroupList, _>(
|
||||||
|
ctx,
|
||||||
get_group_list::Variables {},
|
get_group_list::Variables {},
|
||||||
Msg::ListGroupsResponse,
|
Msg::ListGroupsResponse,
|
||||||
"Error trying to fetch groups",
|
"Error trying to fetch groups",
|
||||||
@@ -71,18 +72,14 @@ impl Component for GroupTable {
|
|||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_groups()}
|
{self.view_groups(ctx)}
|
||||||
{self.view_errors()}
|
{self.view_errors()}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -90,19 +87,20 @@ impl Component for GroupTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GroupTable {
|
impl GroupTable {
|
||||||
fn view_groups(&self) -> Html {
|
fn view_groups(&self, ctx: &Context<Self>) -> Html {
|
||||||
let make_table = |groups: &Vec<Group>| {
|
let make_table = |groups: &Vec<Group>| {
|
||||||
html! {
|
html! {
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{"Groups"}</th>
|
<th>{"Group name"}</th>
|
||||||
|
<th>{"Creation date"}</th>
|
||||||
<th>{"Delete"}</th>
|
<th>{"Delete"}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{groups.iter().map(|u| self.view_group(u)).collect::<Vec<_>>()}
|
{groups.iter().map(|u| self.view_group(ctx, u)).collect::<Vec<_>>()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -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! {
|
html! {
|
||||||
<tr key=group.id>
|
<tr key={group.id}>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::GroupDetails(group.id)>
|
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||||
{&group.display_name}
|
{&group.display_name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{&group.creation_date.naive_local().date()}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<DeleteGroup
|
<DeleteGroup
|
||||||
group=group.clone()
|
group={group.clone()}
|
||||||
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
on_group_deleted={link.callback(Msg::OnGroupDeleted)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::{
|
||||||
|
form::submit::Submit,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use gloo_console::error;
|
||||||
use lldap_auth::*;
|
use lldap_auth::*;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::{prelude::*, services::ConsoleService};
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
pub struct LoginForm {
|
pub struct LoginForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: Form<FormModel>,
|
form: Form<FormModel>,
|
||||||
|
refreshing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fields of the form, with the constraints.
|
/// The fields of the form, with the constraints.
|
||||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct FormModel {
|
pub struct FormModel {
|
||||||
#[validate(length(min = 1, message = "Missing username"))]
|
#[validate(length(min = 1, message = "Missing username"))]
|
||||||
username: String,
|
username: String,
|
||||||
@@ -29,11 +34,13 @@ pub struct FormModel {
|
|||||||
#[derive(Clone, PartialEq, Properties)]
|
#[derive(Clone, PartialEq, Properties)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub on_logged_in: Callback<(String, bool)>,
|
pub on_logged_in: Callback<(String, bool)>,
|
||||||
|
pub password_reset_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Update,
|
Update,
|
||||||
Submit,
|
Submit,
|
||||||
|
AuthenticationRefreshResponse(Result<(String, bool)>),
|
||||||
AuthenticationStartResponse(
|
AuthenticationStartResponse(
|
||||||
(
|
(
|
||||||
opaque::client::login::ClientLogin,
|
opaque::client::login::ClientLogin,
|
||||||
@@ -44,7 +51,12 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<LoginForm> for LoginForm {
|
impl CommonComponent<LoginForm> for LoginForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use anyhow::Context;
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
@@ -57,13 +69,13 @@ impl CommonComponent<LoginForm> for LoginForm {
|
|||||||
opaque::client::login::start_login(&password, &mut rng)
|
opaque::client::login::start_login(&password, &mut rng)
|
||||||
.context("Could not initialize login")?;
|
.context("Could not initialize login")?;
|
||||||
let req = login::ClientLoginStartRequest {
|
let req = login::ClientLoginStartRequest {
|
||||||
username,
|
username: username.into(),
|
||||||
login_start_request: message,
|
login_start_request: message,
|
||||||
};
|
};
|
||||||
self.common
|
self.common
|
||||||
.call_backend(HostService::login_start, req, move |r| {
|
.call_backend(ctx, HostService::login_start(req), move |r| {
|
||||||
Msg::AuthenticationStartResponse((state, r))
|
Msg::AuthenticationStartResponse((state, r))
|
||||||
})?;
|
});
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
Msg::AuthenticationStartResponse((login_start, res)) => {
|
||||||
@@ -74,9 +86,8 @@ impl CommonComponent<LoginForm> for LoginForm {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Common error, we want to print a full error to the console but only a
|
// Common error, we want to print a full error to the console but only a
|
||||||
// simple one to the user.
|
// simple one to the user.
|
||||||
ConsoleService::error(&format!("Invalid username or password: {}", e));
|
error!(&format!("Invalid username or password: {}", e));
|
||||||
self.common.error = Some(anyhow!("Invalid username or password"));
|
self.common.error = Some(anyhow!("Invalid username or password"));
|
||||||
self.common.cancel_task();
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
@@ -86,19 +97,25 @@ impl CommonComponent<LoginForm> for LoginForm {
|
|||||||
credential_finalization: login_finish.message,
|
credential_finalization: login_finish.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::login_finish,
|
ctx,
|
||||||
req,
|
HostService::login_finish(req),
|
||||||
Msg::AuthenticationFinishResponse,
|
Msg::AuthenticationFinishResponse,
|
||||||
)?;
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::AuthenticationFinishResponse(user_info) => {
|
Msg::AuthenticationFinishResponse(user_info) => {
|
||||||
self.common.cancel_task();
|
ctx.props()
|
||||||
self.common
|
|
||||||
.on_logged_in
|
.on_logged_in
|
||||||
.emit(user_info.context("Could not log in")?);
|
.emit(user_info.context("Could not log in")?);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
Msg::AuthenticationRefreshResponse(user_info) => {
|
||||||
|
self.refreshing = false;
|
||||||
|
if let Ok(user_info) = user_info {
|
||||||
|
ctx.props().on_logged_in.emit(user_info);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,26 +128,37 @@ impl Component for LoginForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
LoginForm {
|
let mut app = LoginForm {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: Form::<FormModel>::new(FormModel::default()),
|
form: Form::<FormModel>::new(FormModel::default()),
|
||||||
}
|
refreshing: true,
|
||||||
|
};
|
||||||
|
app.common.call_backend(
|
||||||
|
ctx,
|
||||||
|
HostService::refresh(),
|
||||||
|
Msg::AuthenticationRefreshResponse,
|
||||||
|
);
|
||||||
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<FormModel>;
|
type Field = yew_form::Field<FormModel>;
|
||||||
html! {
|
let password_reset_enabled = ctx.props().password_reset_enabled;
|
||||||
<form
|
let link = &ctx.link();
|
||||||
class="form center-block col-sm-4 col-offset-4">
|
if self.refreshing {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<img src={"spinner.gif"} alt={"Loading"} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<form class="form center-block col-sm-4 col-offset-4">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="input-group-prepend">
|
<div class="input-group-prepend">
|
||||||
<span class="input-group-text">
|
<span class="input-group-text">
|
||||||
@@ -141,11 +169,11 @@ impl Component for LoginForm {
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="username"
|
field_name="username"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="input-group-prepend">
|
<div class="input-group-prepend">
|
||||||
@@ -157,34 +185,37 @@ impl Component for LoginForm {
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="password"
|
field_name="password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
autocomplete="current-password" />
|
autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-3">
|
<Submit
|
||||||
<button
|
text="Login"
|
||||||
type="submit"
|
disabled={self.common.is_task_running()}
|
||||||
class="btn btn-primary"
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||||
disabled=self.common.is_task_running()
|
{ if password_reset_enabled {
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
html! {
|
||||||
{"Login"}
|
<Link
|
||||||
</button>
|
classes="btn-link btn"
|
||||||
<NavButton
|
disabled={self.common.is_task_running()}
|
||||||
classes="btn-link btn"
|
to={AppRoute::StartResetPassword}>
|
||||||
disabled=self.common.is_task_running()
|
{"Forgot your password?"}
|
||||||
route=AppRoute::StartResetPassword>
|
</Link>
|
||||||
{"Forgot your password?"}
|
}
|
||||||
</NavButton>
|
} else {
|
||||||
</div>
|
html!{}
|
||||||
|
}}
|
||||||
|
</Submit>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! { e.to_string() }
|
html! { e.to_string() }
|
||||||
} else { html! {} }
|
} else { html! {} }
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,20 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<LogoutButton> for LogoutButton {
|
impl CommonComponent<LogoutButton> for LogoutButton {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::LogoutRequested => {
|
Msg::LogoutRequested => {
|
||||||
self.common
|
self.common
|
||||||
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
|
.call_backend(ctx, HostService::logout(), Msg::LogoutCompleted);
|
||||||
}
|
}
|
||||||
Msg::LogoutCompleted(res) => {
|
Msg::LogoutCompleted(res) => {
|
||||||
res?;
|
res?;
|
||||||
delete_cookie("user_id")?;
|
delete_cookie("user_id")?;
|
||||||
self.common.on_logged_out.emit(());
|
ctx.props().on_logged_out.emit(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
@@ -45,25 +49,22 @@ impl Component for LogoutButton {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
LogoutButton {
|
LogoutButton {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
onclick={link.callback(|_| Msg::LogoutRequested)}>
|
||||||
{"Logout"}
|
{"Logout"}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
pub mod add_group_member;
|
pub mod add_group_member;
|
||||||
pub mod add_user_to_group;
|
pub mod add_user_to_group;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod avatar;
|
||||||
|
pub mod banner;
|
||||||
pub mod change_password;
|
pub mod change_password;
|
||||||
pub mod create_group;
|
pub mod create_group;
|
||||||
|
pub mod create_group_attribute;
|
||||||
pub mod create_user;
|
pub mod create_user;
|
||||||
|
pub mod create_user_attribute;
|
||||||
pub mod delete_group;
|
pub mod delete_group;
|
||||||
|
pub mod delete_group_attribute;
|
||||||
pub mod delete_user;
|
pub mod delete_user;
|
||||||
|
pub mod delete_user_attribute;
|
||||||
|
pub mod form;
|
||||||
pub mod group_details;
|
pub mod group_details;
|
||||||
|
pub mod group_schema_table;
|
||||||
pub mod group_table;
|
pub mod group_table;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
@@ -17,4 +25,5 @@ pub mod router;
|
|||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod user_details;
|
pub mod user_details;
|
||||||
pub mod user_details_form;
|
pub mod user_details_form;
|
||||||
|
pub mod user_schema_table;
|
||||||
pub mod user_table;
|
pub mod user_table;
|
||||||
|
|||||||
@@ -31,15 +31,18 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
Msg::SubmitRemoveGroup => self.submit_remove_group(ctx),
|
||||||
Msg::RemoveGroupResponse(response) => {
|
Msg::RemoveGroupResponse(response) => {
|
||||||
response?;
|
response?;
|
||||||
self.common.cancel_task();
|
ctx.props()
|
||||||
self.common
|
|
||||||
.on_user_removed_from_group
|
.on_user_removed_from_group
|
||||||
.emit((self.common.username.clone(), self.common.group_id));
|
.emit((ctx.props().username.clone(), ctx.props().group_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -51,11 +54,12 @@ impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupCompon
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RemoveUserFromGroupComponent {
|
impl RemoveUserFromGroupComponent {
|
||||||
fn submit_remove_group(&mut self) {
|
fn submit_remove_group(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
||||||
|
ctx,
|
||||||
remove_user_from_group::Variables {
|
remove_user_from_group::Variables {
|
||||||
user: self.common.username.clone(),
|
user: ctx.props().username.clone(),
|
||||||
group: self.common.group_id,
|
group: ctx.props().group_id,
|
||||||
},
|
},
|
||||||
Msg::RemoveGroupResponse,
|
Msg::RemoveGroupResponse,
|
||||||
"Error trying to initiate removing the user from a group",
|
"Error trying to initiate removing the user from a group",
|
||||||
@@ -67,30 +71,28 @@ impl Component for RemoveUserFromGroupComponent {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
self,
|
self,
|
||||||
|
ctx,
|
||||||
msg,
|
msg,
|
||||||
self.common.on_error.clone(),
|
ctx.props().on_error.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
onclick={link.callback(|_| Msg::SubmitRemoveGroup)}>
|
||||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, NavButton},
|
components::router::{AppRoute, Link},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -18,7 +18,7 @@ pub struct ResetPasswordStep1Form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The fields of the form, with the constraints.
|
/// The fields of the form, with the constraints.
|
||||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct FormModel {
|
pub struct FormModel {
|
||||||
#[validate(length(min = 1, message = "Missing username"))]
|
#[validate(length(min = 1, message = "Missing username"))]
|
||||||
username: String,
|
username: String,
|
||||||
@@ -31,7 +31,11 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::Submit => {
|
Msg::Submit => {
|
||||||
@@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
|||||||
}
|
}
|
||||||
let FormModel { username } = self.form.model();
|
let FormModel { username } = self.form.model();
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::reset_password_step1,
|
ctx,
|
||||||
&username,
|
HostService::reset_password_step1(username),
|
||||||
Msg::PasswordResetResponse,
|
Msg::PasswordResetResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::PasswordResetResponse(response) => {
|
Msg::PasswordResetResponse(response) => {
|
||||||
@@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
ResetPasswordStep1Form {
|
ResetPasswordStep1Form {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: Form::<FormModel>::new(FormModel::default()),
|
form: Form::<FormModel>::new(FormModel::default()),
|
||||||
just_succeeded: false,
|
just_succeeded: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
self.just_succeeded = false;
|
self.just_succeeded = false;
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
type Field = yew_form::Field<FormModel>;
|
type Field = yew_form::Field<FormModel>;
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<form
|
<form
|
||||||
class="form center-block col-sm-4 col-offset-4">
|
class="form center-block col-sm-4 col-offset-4">
|
||||||
@@ -95,11 +96,11 @@ impl Component for ResetPasswordStep1Form {
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
class_invalid="is-invalid has-error"
|
class_invalid="is-invalid has-error"
|
||||||
class_valid="has-success"
|
class_valid="has-success"
|
||||||
form=&self.form
|
form={&self.form}
|
||||||
field_name="username"
|
field_name="username"
|
||||||
placeholder="Username"
|
placeholder="Username or email"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</div>
|
</div>
|
||||||
{ if self.just_succeeded {
|
{ if self.just_succeeded {
|
||||||
html! {
|
html! {
|
||||||
@@ -111,23 +112,24 @@ impl Component for ResetPasswordStep1Form {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||||
|
<i class="bi-check-circle me-2"/>
|
||||||
{"Reset password"}
|
{"Reset password"}
|
||||||
</button>
|
</button>
|
||||||
<NavButton
|
<Link
|
||||||
classes="btn-link btn"
|
classes="btn-link btn"
|
||||||
disabled=self.common.is_task_running()
|
disabled={self.common.is_task_running()}
|
||||||
route=AppRoute::Login>
|
to={AppRoute::Login}>
|
||||||
{"Back"}
|
{"Back"}
|
||||||
</NavButton>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger mb-2">
|
||||||
{e.to_string() }
|
{e.to_string() }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::AppRoute,
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Result};
|
||||||
use lldap_auth::*;
|
use lldap_auth::{
|
||||||
|
opaque::client::registration as opaque_registration,
|
||||||
|
password_reset::ServerPasswordResetResponse, registration,
|
||||||
|
};
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form::Form;
|
use yew_form::Form;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
agent::{RouteAgentDispatcher, RouteRequest},
|
|
||||||
route::Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The fields of the form, with the constraints.
|
/// The fields of the form, with the constraints.
|
||||||
#[derive(Model, Validate, PartialEq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct FormModel {
|
pub struct FormModel {
|
||||||
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
|
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
|
||||||
password: String,
|
password: String,
|
||||||
@@ -29,17 +32,16 @@ pub struct ResetPasswordStep2Form {
|
|||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: Form<FormModel>,
|
form: Form<FormModel>,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
opaque_data: Option<opaque::client::registration::ClientRegistration>,
|
opaque_data: Option<opaque_registration::ClientRegistration>,
|
||||||
route_dispatcher: RouteAgentDispatcher,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Properties)]
|
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
ValidateTokenResponse(Result<String>),
|
ValidateTokenResponse(Result<ServerPasswordResetResponse>),
|
||||||
FormUpdate,
|
FormUpdate,
|
||||||
Submit,
|
Submit,
|
||||||
RegistrationStartResponse(Result<Box<registration::ServerRegistrationStartResponse>>),
|
RegistrationStartResponse(Result<Box<registration::ServerRegistrationStartResponse>>),
|
||||||
@@ -47,11 +49,15 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use anyhow::Context;
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ValidateTokenResponse(response) => {
|
Msg::ValidateTokenResponse(response) => {
|
||||||
self.username = Some(response?);
|
self.username = Some(response?.user_id);
|
||||||
self.common.cancel_task();
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::FormUpdate => Ok(true),
|
Msg::FormUpdate => Ok(true),
|
||||||
@@ -62,25 +68,25 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
|||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
let new_password = self.form.model().password;
|
let new_password = self.form.model().password;
|
||||||
let registration_start_request =
|
let registration_start_request =
|
||||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
opaque_registration::start_registration(new_password.as_bytes(), &mut rng)
|
||||||
.context("Could not initiate password change")?;
|
.context("Could not initiate password change")?;
|
||||||
let req = registration::ClientRegistrationStartRequest {
|
let req = registration::ClientRegistrationStartRequest {
|
||||||
username: self.username.clone().unwrap(),
|
username: self.username.as_ref().unwrap().into(),
|
||||||
registration_start_request: registration_start_request.message,
|
registration_start_request: registration_start_request.message,
|
||||||
};
|
};
|
||||||
self.opaque_data = Some(registration_start_request.state);
|
self.opaque_data = Some(registration_start_request.state);
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_start,
|
ctx,
|
||||||
req,
|
HostService::register_start(req),
|
||||||
Msg::RegistrationStartResponse,
|
Msg::RegistrationStartResponse,
|
||||||
)?;
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Msg::RegistrationStartResponse(res) => {
|
Msg::RegistrationStartResponse(res) => {
|
||||||
let res = res.context("Could not initiate password change")?;
|
let res = res.context("Could not initiate password change")?;
|
||||||
let registration = self.opaque_data.take().expect("Missing registration data");
|
let registration = self.opaque_data.take().expect("Missing registration data");
|
||||||
let mut rng = rand::rngs::OsRng;
|
let mut rng = rand::rngs::OsRng;
|
||||||
let registration_finish = opaque::client::registration::finish_registration(
|
let registration_finish = opaque_registration::finish_registration(
|
||||||
registration,
|
registration,
|
||||||
res.registration_response,
|
res.registration_response,
|
||||||
&mut rng,
|
&mut rng,
|
||||||
@@ -91,17 +97,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
|||||||
registration_upload: registration_finish.message,
|
registration_upload: registration_finish.message,
|
||||||
};
|
};
|
||||||
self.common.call_backend(
|
self.common.call_backend(
|
||||||
HostService::register_finish,
|
ctx,
|
||||||
req,
|
HostService::register_finish(req),
|
||||||
Msg::RegistrationFinishResponse,
|
Msg::RegistrationFinishResponse,
|
||||||
)?;
|
);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Msg::RegistrationFinishResponse(response) => {
|
Msg::RegistrationFinishResponse(response) => {
|
||||||
self.common.cancel_task();
|
|
||||||
if response.is_ok() {
|
if response.is_ok() {
|
||||||
self.route_dispatcher
|
ctx.link().history().unwrap().push(AppRoute::Login);
|
||||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
|
|
||||||
}
|
}
|
||||||
response?;
|
response?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -118,35 +122,28 @@ impl Component for ResetPasswordStep2Form {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut component = ResetPasswordStep2Form {
|
let mut component = ResetPasswordStep2Form {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||||
opaque_data: None,
|
opaque_data: None,
|
||||||
route_dispatcher: RouteAgentDispatcher::new(),
|
|
||||||
username: None,
|
username: None,
|
||||||
};
|
};
|
||||||
let token = component.common.token.clone();
|
let token = ctx.props().token.clone();
|
||||||
component
|
component.common.call_backend(
|
||||||
.common
|
ctx,
|
||||||
.call_backend(
|
HostService::reset_password_step2(token),
|
||||||
HostService::reset_password_step2,
|
Msg::ValidateTokenResponse,
|
||||||
&token,
|
);
|
||||||
Msg::ValidateTokenResponse,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
component
|
component
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
match (&self.username, &self.common.error) {
|
match (&self.username, &self.common.error) {
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
return html! {
|
return html! {
|
||||||
@@ -155,68 +152,44 @@ impl Component for ResetPasswordStep2Form {
|
|||||||
}
|
}
|
||||||
(None, Some(e)) => {
|
(None, Some(e)) => {
|
||||||
return html! {
|
return html! {
|
||||||
<div class="alert alert-danger">
|
<>
|
||||||
{e.to_string() }
|
<div class="alert alert-danger">
|
||||||
</div>
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
classes="btn-link btn"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
to={AppRoute::Login}>
|
||||||
|
{"Back"}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
};
|
};
|
||||||
type Field = yew_form::Field<FormModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h2>{"Reset your password"}</h2>
|
<h2>{"Reset your password"}</h2>
|
||||||
<form
|
<form class="form">
|
||||||
class="form">
|
<Field<FormModel>
|
||||||
<div class="form-group row">
|
label="New password"
|
||||||
<label for="new_password"
|
required=true
|
||||||
class="form-label col-sm-2 col-form-label">
|
form={&self.form}
|
||||||
{"New password*:"}
|
field_name="password"
|
||||||
</label>
|
autocomplete="new-password"
|
||||||
<div class="col-sm-10">
|
input_type="password"
|
||||||
<Field
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
form=&self.form
|
<Field<FormModel>
|
||||||
field_name="password"
|
label="Confirm password"
|
||||||
class="form-control"
|
required=true
|
||||||
class_invalid="is-invalid has-error"
|
form={&self.form}
|
||||||
class_valid="has-success"
|
field_name="confirm_password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<div class="invalid-feedback">
|
<Submit
|
||||||
{&self.form.field_message("password")}
|
disabled={self.common.is_task_running()}
|
||||||
</div>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="confirm_password"
|
|
||||||
class="form-label col-sm-2 col-form-label">
|
|
||||||
{"Confirm password*:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<Field
|
|
||||||
form=&self.form
|
|
||||||
field_name="confirm_password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
input_type="password"
|
|
||||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("confirm_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mt-2">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary col-sm-1 col-form-label"
|
|
||||||
type="submit"
|
|
||||||
disabled=self.common.is_task_running()
|
|
||||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
|
||||||
{"Submit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
use yew_router::{
|
use yew_router::Routable;
|
||||||
components::{RouterAnchor, RouterButton},
|
|
||||||
Switch,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Switch, Debug, Clone)]
|
#[derive(Routable, Debug, Clone, PartialEq)]
|
||||||
pub enum AppRoute {
|
pub enum AppRoute {
|
||||||
#[to = "/login"]
|
#[at("/login")]
|
||||||
Login,
|
Login,
|
||||||
#[to = "/reset-password/step1"]
|
#[at("/reset-password/step1")]
|
||||||
StartResetPassword,
|
StartResetPassword,
|
||||||
#[to = "/reset-password/step2/{token}"]
|
#[at("/reset-password/step2/:token")]
|
||||||
FinishResetPassword(String),
|
FinishResetPassword { token: String },
|
||||||
#[to = "/users/create"]
|
#[at("/users/create")]
|
||||||
CreateUser,
|
CreateUser,
|
||||||
#[to = "/users"]
|
#[at("/users")]
|
||||||
ListUsers,
|
ListUsers,
|
||||||
#[to = "/user/{user_id}/password"]
|
#[at("/user/:user_id/password")]
|
||||||
ChangePassword(String),
|
ChangePassword { user_id: String },
|
||||||
#[to = "/user/{user_id}"]
|
#[at("/user/:user_id")]
|
||||||
UserDetails(String),
|
UserDetails { user_id: String },
|
||||||
#[to = "/groups/create"]
|
#[at("/groups/create")]
|
||||||
CreateGroup,
|
CreateGroup,
|
||||||
#[to = "/groups"]
|
#[at("/groups")]
|
||||||
ListGroups,
|
ListGroups,
|
||||||
#[to = "/group/{group_id}"]
|
#[at("/group/:group_id")]
|
||||||
GroupDetails(i64),
|
GroupDetails { group_id: i64 },
|
||||||
#[to = "/"]
|
#[at("/user-attributes")]
|
||||||
|
ListUserSchema,
|
||||||
|
#[at("/user-attributes/create")]
|
||||||
|
CreateUserAttribute,
|
||||||
|
#[at("/group-attributes")]
|
||||||
|
ListGroupSchema,
|
||||||
|
#[at("/group-attributes/create")]
|
||||||
|
CreateGroupAttribute,
|
||||||
|
#[at("/")]
|
||||||
Index,
|
Index,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Link = RouterAnchor<AppRoute>;
|
pub type Link = yew_router::components::Link<AppRoute>;
|
||||||
|
pub type Redirect = yew_router::components::Redirect<AppRoute>;
|
||||||
pub type NavButton = RouterButton<AppRoute>;
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
use yew::{html::ChangeData, prelude::*};
|
use yew::prelude::*;
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
pub struct Select {
|
pub struct Select {
|
||||||
link: ComponentLink<Self>,
|
|
||||||
props: SelectProps,
|
|
||||||
node_ref: NodeRef,
|
node_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,100 +11,70 @@ pub struct SelectProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub enum SelectMsg {
|
pub enum SelectMsg {
|
||||||
OnSelectChange(ChangeData),
|
OnSelectChange,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Select {
|
impl Select {
|
||||||
fn get_nth_child_props(&self, nth: i32) -> Option<SelectOptionProps> {
|
fn get_nth_child_props(&self, ctx: &Context<Self>, nth: i32) -> Option<SelectOptionProps> {
|
||||||
if nth == -1 {
|
if nth == -1 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.props
|
ctx.props()
|
||||||
.children
|
.children
|
||||||
.iter()
|
.iter()
|
||||||
.nth(nth as usize)
|
.nth(nth as usize)
|
||||||
.map(|child| child.props)
|
.map(|child| (*child.props).clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_selection_update(&self) {
|
fn send_selection_update(&self, ctx: &Context<Self>) {
|
||||||
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
|
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
|
||||||
self.props
|
ctx.props()
|
||||||
.on_selection_change
|
.on_selection_change
|
||||||
.emit(self.get_nth_child_props(select_node.selected_index()))
|
.emit(self.get_nth_child_props(ctx, select_node.selected_index()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for Select {
|
impl Component for Select {
|
||||||
type Message = SelectMsg;
|
type Message = SelectMsg;
|
||||||
type Properties = SelectProps;
|
type Properties = SelectProps;
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(_: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
link,
|
|
||||||
props,
|
|
||||||
node_ref: NodeRef::default(),
|
node_ref: NodeRef::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered(&mut self, _first_render: bool) {
|
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
|
||||||
self.send_selection_update();
|
self.send_selection_update(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, _: Self::Message) -> bool {
|
||||||
let SelectMsg::OnSelectChange(data) = msg;
|
self.send_selection_update(ctx);
|
||||||
match data {
|
|
||||||
ChangeData::Select(_) => self.send_selection_update(),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
self.props.children.neq_assign(props.children)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<select
|
<select class="form-select"
|
||||||
ref=self.node_ref.clone()
|
ref={self.node_ref.clone()}
|
||||||
disabled=self.props.children.is_empty()
|
disabled={ctx.props().children.is_empty()}
|
||||||
onchange=self.link.callback(SelectMsg::OnSelectChange)>
|
onchange={ctx.link().callback(|_| SelectMsg::OnSelectChange)}>
|
||||||
{ self.props.children.clone() }
|
{ ctx.props().children.clone() }
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SelectOption {
|
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
|
||||||
props: SelectOptionProps,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
|
||||||
pub struct SelectOptionProps {
|
pub struct SelectOptionProps {
|
||||||
pub value: String,
|
pub value: String,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for SelectOption {
|
#[function_component(SelectOption)]
|
||||||
type Message = ();
|
pub fn select_option(props: &SelectOptionProps) -> Html {
|
||||||
type Properties = SelectOptionProps;
|
html! {
|
||||||
|
<option value={props.value.clone()}>
|
||||||
fn create(props: Self::Properties, _: ComponentLink<Self>) -> Self {
|
{&props.text}
|
||||||
Self { props }
|
</option>
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, _: Self::Message) -> ShouldRender {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
|
||||||
self.props.neq_assign(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
|
||||||
<option value=self.props.value.clone()>
|
|
||||||
{&self.props.text}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ use crate::{
|
|||||||
components::{
|
components::{
|
||||||
add_user_to_group::AddUserToGroupComponent,
|
add_user_to_group::AddUserToGroupComponent,
|
||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link, NavButton},
|
router::{AppRoute, Link},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
},
|
}, infra::{schema::AttributeType, common_component::{CommonComponent, CommonComponentParts}},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
convert_attribute_type
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
@@ -22,6 +22,10 @@ pub struct GetUserDetails;
|
|||||||
|
|
||||||
pub type User = get_user_details::GetUserDetailsUser;
|
pub type User = get_user_details::GetUserDetailsUser;
|
||||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||||
|
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||||
|
pub type AttributeSchema = get_user_details::GetUserDetailsUserAttributesSchema;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_details::AttributeType);
|
||||||
|
|
||||||
pub struct UserDetails {
|
pub struct UserDetails {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
@@ -40,14 +44,14 @@ pub enum Msg {
|
|||||||
OnUserRemovedFromGroup((String, i64)),
|
OnUserRemovedFromGroup((String, i64)),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetails> for UserDetails {
|
impl CommonComponent<UserDetails> for UserDetails {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserDetailsResponse(response) => match response {
|
Msg::UserDetailsResponse(response) => match response {
|
||||||
Ok(user) => self.user = Some(user.user),
|
Ok(user) => self.user = Some(user.user),
|
||||||
@@ -77,10 +81,11 @@ impl CommonComponent<UserDetails> for UserDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserDetails {
|
impl UserDetails {
|
||||||
fn get_user_details(&mut self) {
|
fn get_user_details(&mut self, ctx: &Context<Self>) {
|
||||||
self.common.call_graphql::<GetUserDetails, _>(
|
self.common.call_graphql::<GetUserDetails, _>(
|
||||||
|
ctx,
|
||||||
get_user_details::Variables {
|
get_user_details::Variables {
|
||||||
id: self.common.username.clone(),
|
id: ctx.props().username.clone(),
|
||||||
},
|
},
|
||||||
Msg::UserDetailsResponse,
|
Msg::UserDetailsResponse,
|
||||||
"Error trying to fetch user details",
|
"Error trying to fetch user details",
|
||||||
@@ -99,24 +104,25 @@ impl UserDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_group_memberships(&self, u: &User) -> Html {
|
fn view_group_memberships(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
let make_group_row = |group: &Group| {
|
let make_group_row = |group: &Group| {
|
||||||
let display_name = group.display_name.clone();
|
let display_name = group.display_name.clone();
|
||||||
html! {
|
html! {
|
||||||
<tr key="groupRow_".to_string() + &display_name>
|
<tr key={"groupRow_".to_string() + &display_name}>
|
||||||
{if self.common.is_admin { html! {
|
{if ctx.props().is_admin { html! {
|
||||||
<>
|
<>
|
||||||
<td>
|
<td>
|
||||||
<Link route=AppRoute::GroupDetails(group.id)>
|
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||||
{&group.display_name}
|
{&group.display_name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<RemoveUserFromGroupComponent
|
<RemoveUserFromGroupComponent
|
||||||
username=u.id.clone()
|
username={u.id.clone()}
|
||||||
group_id=group.id
|
group_id={group.id}
|
||||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
} } else { html! {
|
} } else { html! {
|
||||||
@@ -129,18 +135,18 @@ impl UserDetails {
|
|||||||
<>
|
<>
|
||||||
<h5 class="row m-3 fw-bold">{"Group memberships"}</h5>
|
<h5 class="row m-3 fw-bold">{"Group memberships"}</h5>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr key="headerRow">
|
<tr key="headerRow">
|
||||||
<th>{"Group"}</th>
|
<th>{"Group"}</th>
|
||||||
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
{ if ctx.props().is_admin { html!{ <th></th> }} else { html!{} }}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{if u.groups.is_empty() {
|
{if u.groups.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
<tr key="EmptyRow">
|
<tr key="EmptyRow">
|
||||||
<td>{"Not member of any group"}</td>
|
<td>{"This user is not a member of any groups."}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -153,14 +159,15 @@ impl UserDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_add_group_button(&self, u: &User) -> Html {
|
fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||||
if self.common.is_admin {
|
let link = &ctx.link();
|
||||||
|
if ctx.props().is_admin {
|
||||||
html! {
|
html! {
|
||||||
<AddUserToGroupComponent
|
<AddUserToGroupComponent
|
||||||
username=u.id.clone()
|
username={u.id.clone()}
|
||||||
groups=u.groups.clone()
|
groups={u.groups.clone()}
|
||||||
on_error=self.common.callback(Msg::OnError)
|
on_error={link.callback(Msg::OnError)}
|
||||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
@@ -172,24 +179,20 @@ impl Component for UserDetails {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
user: None,
|
user: None,
|
||||||
};
|
};
|
||||||
table.get_user_details();
|
table.get_user_details(ctx);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
match (&self.user, &self.common.error) {
|
match (&self.user, &self.common.error) {
|
||||||
(None, None) => html! {{"Loading..."}},
|
(None, None) => html! {{"Loading..."}},
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
@@ -197,18 +200,20 @@ impl Component for UserDetails {
|
|||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{u.id.to_string()}</h3>
|
<h3>{u.id.to_string()}</h3>
|
||||||
<UserDetailsForm
|
<div class="d-flex flex-row-reverse">
|
||||||
user=u.clone()
|
<Link
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
to={AppRoute::ChangePassword{user_id: u.id.clone()}}
|
||||||
<div class="row justify-content-center">
|
classes="btn btn-secondary">
|
||||||
<NavButton
|
<i class="bi-key me-2"></i>
|
||||||
route=AppRoute::ChangePassword(u.id.clone())
|
{"Modify password"}
|
||||||
classes="btn btn-primary col-auto">
|
</Link>
|
||||||
{"Change password"}
|
|
||||||
</NavButton>
|
|
||||||
</div>
|
</div>
|
||||||
{self.view_group_memberships(u)}
|
<div>
|
||||||
{self.view_add_group_button(u)}
|
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||||
|
</div>
|
||||||
|
<UserDetailsForm user={u.clone()} />
|
||||||
|
{self.view_group_memberships(ctx, u)}
|
||||||
|
{self.view_add_group_button(ctx, u)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,55 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::user_details::User,
|
components::{
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
form::{attribute_input::SingleAttributeInput, field::Field, static_value::StaticValue, submit::Submit},
|
||||||
|
user_details::{AttributeSchema, User},
|
||||||
|
}, convert_attribute_type, infra::{common_component::{CommonComponent, CommonComponentParts}, schema::AttributeType}
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, bail, Error, Ok, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use gloo_file::{
|
||||||
|
callbacks::{read_as_bytes, FileReader},
|
||||||
|
File,
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator::HasLen;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
|
use web_sys::{FileList, FormData, HtmlFormElement, HtmlInputElement, InputEvent};
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
|
use super::user_details::Attribute;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct JsFile {
|
||||||
|
file: Option<File>,
|
||||||
|
contents: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToString for JsFile {
|
||||||
|
fn to_string(&self) -> String {
|
||||||
|
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for JsFile {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self> {
|
||||||
|
if s.is_empty() {
|
||||||
|
Ok(JsFile::default())
|
||||||
|
} else {
|
||||||
|
bail!("Building file from non-empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The fields of the form, with the editable details and the constraints.
|
/// The fields of the form, with the editable details and the constraints.
|
||||||
#[derive(Model, Validate, PartialEq, Clone)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
||||||
pub struct UserModel {
|
pub struct UserModel {
|
||||||
#[validate(email)]
|
#[validate(email)]
|
||||||
email: String,
|
email: String,
|
||||||
#[validate(length(min = 1, message = "Display name is required"))]
|
|
||||||
display_name: String,
|
display_name: String,
|
||||||
first_name: String,
|
first_name: String,
|
||||||
last_name: String,
|
last_name: String,
|
||||||
@@ -25,7 +61,7 @@ pub struct UserModel {
|
|||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/update_user.graphql",
|
query_path = "queries/update_user.graphql",
|
||||||
response_derives = "Debug",
|
response_derives = "Debug",
|
||||||
variables_derives = "Clone,PartialEq",
|
variables_derives = "Clone,PartialEq,Eq",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
)]
|
)]
|
||||||
pub struct UpdateUser;
|
pub struct UpdateUser;
|
||||||
@@ -34,33 +70,98 @@ pub struct UpdateUser;
|
|||||||
pub struct UserDetailsForm {
|
pub struct UserDetailsForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<UserModel>,
|
form: yew_form::Form<UserModel>,
|
||||||
|
// None means that the avatar hasn't changed.
|
||||||
|
avatar: Option<JsFile>,
|
||||||
|
reader: Option<FileReader>,
|
||||||
/// True if we just successfully updated the user, to display a success message.
|
/// True if we just successfully updated the user, to display a success message.
|
||||||
just_updated: bool,
|
just_updated: bool,
|
||||||
|
user: User,
|
||||||
|
form_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
/// A form field changed.
|
/// A form field changed.
|
||||||
Update,
|
Update,
|
||||||
|
/// A new file was selected.
|
||||||
|
FileSelected(File),
|
||||||
/// The "Submit" button was clicked.
|
/// The "Submit" button was clicked.
|
||||||
SubmitClicked,
|
SubmitClicked,
|
||||||
|
/// The "Clear" button for the avatar was clicked.
|
||||||
|
ClearAvatarClicked,
|
||||||
|
/// A picked file finished loading.
|
||||||
|
FileLoaded(String, Result<Vec<u8>>),
|
||||||
/// We got the response from the server about our update message.
|
/// We got the response from the server about our update message.
|
||||||
UserUpdated(Result<update_user::ResponseData>),
|
UserUpdated(Result<update_user::ResponseData>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(yew::Properties, Clone, PartialEq)]
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
pub struct Props {
|
pub struct Props {
|
||||||
/// The current user details.
|
/// The current user details.
|
||||||
pub user: User,
|
pub user: User,
|
||||||
/// Callback to report errors (e.g. server error).
|
|
||||||
pub on_error: Callback<Error>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
Msg::FileSelected(new_avatar) => {
|
||||||
|
if self
|
||||||
|
.avatar
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||||
|
!= Some(new_avatar.name())
|
||||||
|
{
|
||||||
|
let file_name = new_avatar.name();
|
||||||
|
let link = ctx.link().clone();
|
||||||
|
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||||
|
link.send_message(Msg::FileLoaded(
|
||||||
|
file_name,
|
||||||
|
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||||
|
))
|
||||||
|
}));
|
||||||
|
self.avatar = Some(JsFile {
|
||||||
|
file: Some(new_avatar),
|
||||||
|
contents: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||||
|
Msg::ClearAvatarClicked => {
|
||||||
|
self.avatar = Some(JsFile::default());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||||
|
Msg::FileLoaded(file_name, data) => {
|
||||||
|
if let Some(avatar) = &mut self.avatar {
|
||||||
|
if let Some(file) = &avatar.file {
|
||||||
|
if file.name() == file_name {
|
||||||
|
let data = data?;
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
|
// Clear the selection.
|
||||||
|
self.avatar = None;
|
||||||
|
bail!("Chosen image is not a valid JPEG");
|
||||||
|
} else {
|
||||||
|
avatar.contents = Some(data);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reader = None;
|
||||||
|
Ok(false)
|
||||||
|
} // Msg::OnSubmit(e) => {
|
||||||
|
// e.prevent_default();
|
||||||
|
// let form: HtmlFormElement = e.target_unchecked_into();
|
||||||
|
// let data = FormData::new_with_form(&form).unwrap();
|
||||||
|
// log!(format!("form data{:#?}", data));
|
||||||
|
// log!(format!("form data data{:#?}", *data));
|
||||||
|
// Ok(true)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,158 +174,236 @@ impl Component for UserDetailsForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = Props;
|
type Properties = Props;
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let model = UserModel {
|
let model = UserModel {
|
||||||
email: props.user.email.clone(),
|
email: ctx.props().user.email.clone(),
|
||||||
display_name: props.user.display_name.clone(),
|
display_name: ctx.props().user.display_name.clone(),
|
||||||
first_name: props.user.first_name.clone(),
|
first_name: ctx.props().user.first_name.clone(),
|
||||||
last_name: props.user.last_name.clone(),
|
last_name: ctx.props().user.last_name.clone(),
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::new(model),
|
form: yew_form::Form::new(model),
|
||||||
|
avatar: None,
|
||||||
just_updated: false,
|
just_updated: false,
|
||||||
|
reader: None,
|
||||||
|
user: ctx.props().user.clone(),
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
self.just_updated = false;
|
self.just_updated = false;
|
||||||
CommonComponentParts::<Self>::update_and_report_error(
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
self,
|
|
||||||
msg,
|
|
||||||
self.common.on_error.clone(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
let link = &ctx.link();
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
let avatar_string = match &self.avatar {
|
||||||
type Field = yew_form::Field<UserModel>;
|
Some(avatar) => {
|
||||||
|
let avatar_base64 = to_base64(avatar);
|
||||||
|
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||||
|
}
|
||||||
|
None => self.user.avatar.as_deref().unwrap_or("").to_owned(),
|
||||||
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<form class="form">
|
<form class="form">
|
||||||
<div class="form-group row mb-3">
|
<StaticValue label="User ID" id="userId">
|
||||||
<label for="userId"
|
<i>{&self.user.id}</i>
|
||||||
|
</StaticValue>
|
||||||
|
<StaticValue label="Creation date" id="creationDate">
|
||||||
|
{&self.user.creation_date.naive_local().date()}
|
||||||
|
</StaticValue>
|
||||||
|
<StaticValue label="UUID" id="uuid">
|
||||||
|
{&self.user.uuid}
|
||||||
|
</StaticValue>
|
||||||
|
<Field<UserModel>
|
||||||
|
form={&self.form}
|
||||||
|
required=true
|
||||||
|
label="Email"
|
||||||
|
field_name="email"
|
||||||
|
input_type="email"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Field<UserModel>
|
||||||
|
form={&self.form}
|
||||||
|
label="Display name"
|
||||||
|
field_name="display_name"
|
||||||
|
autocomplete="name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Field<UserModel>
|
||||||
|
form={&self.form}
|
||||||
|
label="First name"
|
||||||
|
field_name="first_name"
|
||||||
|
autocomplete="given-name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Field<UserModel>
|
||||||
|
form={&self.form}
|
||||||
|
label="Last name"
|
||||||
|
field_name="last_name"
|
||||||
|
autocomplete="family-name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<div class="form-group row align-items-center mb-3">
|
||||||
|
<label for="avatar"
|
||||||
class="form-label col-4 col-form-label">
|
class="form-label col-4 col-form-label">
|
||||||
{"User ID: "}
|
{"Avatar: "}
|
||||||
</label>
|
</label>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<span id="userId" class="form-constrol-static">{&self.common.user.id}</span>
|
<div class="row align-items-center">
|
||||||
</div>
|
<div class="col-5">
|
||||||
</div>
|
<input
|
||||||
<div class="form-group row mb-3">
|
class="form-control"
|
||||||
<label for="email"
|
id="avatarInput"
|
||||||
class="form-label col-4 col-form-label">
|
type="file"
|
||||||
{"Email*: "}
|
accept="image/jpeg"
|
||||||
</label>
|
oninput={link.callback(|e: InputEvent| {
|
||||||
<div class="col-8">
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
<Field
|
Self::upload_files(input.files())
|
||||||
class="form-control"
|
})} />
|
||||||
class_invalid="is-invalid has-error"
|
</div>
|
||||||
class_valid="has-success"
|
<div class="col-3">
|
||||||
form=&self.form
|
<button
|
||||||
field_name="email"
|
class="btn btn-secondary col-auto"
|
||||||
autocomplete="email"
|
id="avatarClear"
|
||||||
oninput=self.common.callback(|_| Msg::Update) />
|
disabled={self.common.is_task_running()}
|
||||||
<div class="invalid-feedback">
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
|
||||||
{&self.form.field_message("email")}
|
{"Clear"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
{
|
||||||
|
if !avatar_string.is_empty() {
|
||||||
|
html!{
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||||
|
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||||
|
alt="Avatar" />
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group row mb-3">
|
{self.user.attributes.iter().map(get_custom_attribute_input).collect::<Vec<_>>()}
|
||||||
<label for="display_name"
|
<Submit
|
||||||
class="form-label col-4 col-form-label">
|
text="Save changes"
|
||||||
{"Display Name*: "}
|
disabled={self.common.is_task_running()}
|
||||||
</label>
|
onclick={link.callback(|e: MouseEvent| {Msg::SubmitClicked})} />
|
||||||
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AttributeValue = (String, Vec<String>);
|
||||||
|
|
||||||
|
fn get_values_from_form_data(
|
||||||
|
schema: Vec<AttributeSchema>,
|
||||||
|
form: &FormData,
|
||||||
|
) -> Result<Vec<AttributeValue>> {
|
||||||
|
schema
|
||||||
|
.into_iter()
|
||||||
|
.map(|attr| -> Result<AttributeValue> {
|
||||||
|
let val = form
|
||||||
|
.get_all(attr.name.as_str())
|
||||||
|
.iter()
|
||||||
|
.map(|js_val| js_val.as_string().unwrap())
|
||||||
|
.filter(|val| !val.is_empty())
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
if val.length() > 1 && !attr.is_list {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Multiple values supplied for non-list attribute {}",
|
||||||
|
attr.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((attr.name.clone(), val))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(attribute: &Attribute) -> Html {
|
||||||
|
if attribute.schema.is_list {
|
||||||
|
html!{<p>{"list attr"}</p>}
|
||||||
|
} else {
|
||||||
|
let value = if attribute.value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(attribute.value[0].clone())
|
||||||
|
};
|
||||||
|
html!{<SingleAttributeInput name={attribute.name.clone()} attribute_type={Into::<AttributeType>::into(attribute.schema.attribute_type.clone())} value={value}/>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl UserDetailsForm {
|
impl UserDetailsForm {
|
||||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
bail!("Invalid inputs");
|
bail!("Invalid inputs");
|
||||||
}
|
}
|
||||||
let base_user = &self.common.user;
|
if let Some(JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: None,
|
||||||
|
}) = &self.avatar
|
||||||
|
{
|
||||||
|
bail!("Image file hasn't finished loading, try again");
|
||||||
|
}
|
||||||
|
let form = self.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 mut all_values = get_values_from_form_data(
|
||||||
|
self.user
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.map(|attr| attr.schema.clone())
|
||||||
|
.filter(|attr| !attr.is_hardcoded)
|
||||||
|
.filter(|attr| attr.is_editable)
|
||||||
|
.collect(),
|
||||||
|
&form_data,
|
||||||
|
)?;
|
||||||
|
let base_user = &self.user;
|
||||||
|
let base_attrs = &self.user.attributes;
|
||||||
|
all_values.retain(|(name, val)| {
|
||||||
|
let name = name.clone();
|
||||||
|
let base_val = base_attrs
|
||||||
|
.into_iter()
|
||||||
|
.find(|base_val| base_val.name == name)
|
||||||
|
.unwrap();
|
||||||
|
let new_values = val.clone();
|
||||||
|
base_val.value != new_values
|
||||||
|
});
|
||||||
|
let remove_names: Option<Vec<String>> = if all_values.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(all_values.iter().map(|(name, _)| name.clone()).collect())
|
||||||
|
};
|
||||||
|
let insert_attrs: Option<Vec<update_user::AttributeValueInput>> = if remove_names.is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(all_values.into_iter().map(|(name, value)| update_user::AttributeValueInput{name, value}).collect())
|
||||||
|
};
|
||||||
let mut user_input = update_user::UpdateUserInput {
|
let mut user_input = update_user::UpdateUserInput {
|
||||||
id: self.common.user.id.clone(),
|
id: self.user.id.clone(),
|
||||||
email: None,
|
email: None,
|
||||||
displayName: None,
|
displayName: None,
|
||||||
firstName: None,
|
firstName: None,
|
||||||
lastName: None,
|
lastName: None,
|
||||||
|
avatar: None,
|
||||||
|
removeAttributes: remove_names,
|
||||||
|
insertAttributes: insert_attrs,
|
||||||
};
|
};
|
||||||
let default_user_input = user_input.clone();
|
let default_user_input = user_input.clone();
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
@@ -241,12 +420,16 @@ impl UserDetailsForm {
|
|||||||
if base_user.last_name != model.last_name {
|
if base_user.last_name != model.last_name {
|
||||||
user_input.lastName = Some(model.last_name);
|
user_input.lastName = Some(model.last_name);
|
||||||
}
|
}
|
||||||
|
if let Some(avatar) = &self.avatar {
|
||||||
|
user_input.avatar = Some(to_base64(avatar)?);
|
||||||
|
}
|
||||||
// Nothing changed.
|
// Nothing changed.
|
||||||
if user_input == default_user_input {
|
if user_input == default_user_input {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let req = update_user::Variables { user: user_input };
|
let req = update_user::Variables { user: user_input };
|
||||||
self.common.call_graphql::<UpdateUser, _>(
|
self.common.call_graphql::<UpdateUser, _>(
|
||||||
|
ctx,
|
||||||
req,
|
req,
|
||||||
Msg::UserUpdated,
|
Msg::UserUpdated,
|
||||||
"Error trying to update user",
|
"Error trying to update user",
|
||||||
@@ -255,23 +438,56 @@ impl UserDetailsForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||||
self.common.cancel_task();
|
r?;
|
||||||
match r {
|
let model = self.form.model();
|
||||||
Err(e) => return Err(e),
|
self.user.email = model.email;
|
||||||
Ok(_) => {
|
self.user.display_name = model.display_name;
|
||||||
let model = self.form.model();
|
self.user.first_name = model.first_name;
|
||||||
self.common.user = User {
|
self.user.last_name = model.last_name;
|
||||||
id: self.common.user.id.clone(),
|
if let Some(avatar) = &self.avatar {
|
||||||
email: model.email,
|
self.user.avatar = Some(to_base64(avatar)?);
|
||||||
display_name: model.display_name,
|
}
|
||||||
first_name: model.first_name,
|
self.just_updated = true;
|
||||||
last_name: model.last_name,
|
|
||||||
creation_date: self.common.user.creation_date,
|
|
||||||
groups: self.common.user.groups.clone(),
|
|
||||||
};
|
|
||||||
self.just_updated = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn upload_files(files: Option<FileList>) -> Msg {
|
||||||
|
if let Some(files) = files {
|
||||||
|
if files.length() > 0 {
|
||||||
|
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||||
|
} else {
|
||||||
|
Msg::Update
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Msg::Update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||||
|
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||||
|
.decode()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_base64(file: &JsFile) -> Result<String> {
|
||||||
|
match file {
|
||||||
|
JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: _,
|
||||||
|
} => Ok(String::new()),
|
||||||
|
JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: None,
|
||||||
|
} => bail!("Image file hasn't finished loading, try again"),
|
||||||
|
JsFile {
|
||||||
|
file: Some(_),
|
||||||
|
contents: Some(data),
|
||||||
|
} => {
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
|
bail!("Chosen image is not a valid JPEG");
|
||||||
|
}
|
||||||
|
Ok(base64::encode(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
198
app/src/components/user_schema_table.rs
Normal file
198
app/src/components/user_schema_table.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
delete_user_attribute::DeleteUserAttribute,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, Error, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_user_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetUserAttributesSchema;
|
||||||
|
|
||||||
|
use get_user_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub hardcoded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserSchemaTable {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
attributes: Option<Vec<Attribute>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
|
OnAttributeDeleted(String),
|
||||||
|
OnError(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<UserSchemaTable> for UserSchemaTable {
|
||||||
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes = Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::OnError(e) => Err(e),
|
||||||
|
Msg::OnAttributeDeleted(attribute_name) => {
|
||||||
|
match self.attributes {
|
||||||
|
None => {
|
||||||
|
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||||
|
Err(anyhow!("invalid state"))
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
self.attributes
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|a| a.name != attribute_name);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for UserSchemaTable {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let mut table = UserSchemaTable {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
attributes: None,
|
||||||
|
};
|
||||||
|
table.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_user_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch user schema",
|
||||||
|
);
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
{self.view_attributes(ctx)}
|
||||||
|
{self.view_errors()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserSchemaTable {
|
||||||
|
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
let make_table = |attributes: &Vec<Attribute>| {
|
||||||
|
html! {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{"Attribute name"}</th>
|
||||||
|
<th>{"Type"}</th>
|
||||||
|
<th>{"Editable"}</th>
|
||||||
|
<th>{"Visible"}</th>
|
||||||
|
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match &self.attributes {
|
||||||
|
None => html! {{"Loading..."}},
|
||||||
|
Some(attributes) => {
|
||||||
|
let mut attributes = attributes.clone();
|
||||||
|
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||||
|
make_table(&attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||||
|
let checkmark = html! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||||
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||||
|
</svg>
|
||||||
|
};
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
html! {
|
||||||
|
<tr key={attribute.name.clone()}>
|
||||||
|
<td>{&attribute.name}</td>
|
||||||
|
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||||
|
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
|
||||||
|
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||||
|
{
|
||||||
|
if hardcoded {
|
||||||
|
html!{}
|
||||||
|
} else {
|
||||||
|
html!{
|
||||||
|
<td>
|
||||||
|
<DeleteUserAttribute
|
||||||
|
attribute_name={attribute.name.clone()}
|
||||||
|
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
|
||||||
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_errors(&self) -> Html {
|
||||||
|
match &self.common.error {
|
||||||
|
None => html! {},
|
||||||
|
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ListUserSchema)]
|
||||||
|
pub fn list_user_schema() -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<UserSchemaTable hardcoded={true} />
|
||||||
|
<UserSchemaTable hardcoded={false} />
|
||||||
|
<Link classes="btn btn-primary" to={AppRoute::CreateUserAttribute}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Create an attribute"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserTable> for UserTable {
|
impl CommonComponent<UserTable> for UserTable {
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::ListUsersResponse(users) => {
|
Msg::ListUsersResponse(users) => {
|
||||||
self.users = Some(users?.users.into_iter().collect());
|
self.users = Some(users?.users.into_iter().collect());
|
||||||
@@ -55,8 +55,9 @@ impl CommonComponent<UserTable> for UserTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserTable {
|
impl UserTable {
|
||||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
fn get_users(&mut self, ctx: &Context<Self>, req: Option<RequestFilter>) {
|
||||||
self.common.call_graphql::<ListUsersQuery, _>(
|
self.common.call_graphql::<ListUsersQuery, _>(
|
||||||
|
ctx,
|
||||||
list_users_query::Variables { filters: req },
|
list_users_query::Variables { filters: req },
|
||||||
Msg::ListUsersResponse,
|
Msg::ListUsersResponse,
|
||||||
"Error trying to fetch users",
|
"Error trying to fetch users",
|
||||||
@@ -68,27 +69,23 @@ impl Component for UserTable {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = UserTable {
|
let mut table = UserTable {
|
||||||
common: CommonComponentParts::<Self>::create(props, link),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
users: None,
|
users: None,
|
||||||
};
|
};
|
||||||
table.get_users(None);
|
table.get_users(ctx, None);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
CommonComponentParts::<Self>::update(self, msg)
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Html {
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_users()}
|
{self.view_users(ctx)}
|
||||||
{self.view_errors()}
|
{self.view_errors()}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -96,11 +93,11 @@ impl Component for UserTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserTable {
|
impl UserTable {
|
||||||
fn view_users(&self) -> Html {
|
fn view_users(&self, ctx: &Context<Self>) -> Html {
|
||||||
let make_table = |users: &Vec<User>| {
|
let make_table = |users: &Vec<User>| {
|
||||||
html! {
|
html! {
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{"User ID"}</th>
|
<th>{"User ID"}</th>
|
||||||
@@ -113,7 +110,7 @@ impl UserTable {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.iter().map(|u| self.view_user(u)).collect::<Vec<_>>()}
|
{users.iter().map(|u| self.view_user(ctx, u)).collect::<Vec<_>>()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,20 +122,21 @@ impl UserTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_user(&self, user: &User) -> Html {
|
fn view_user(&self, ctx: &Context<Self>, user: &User) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<tr key=user.id.clone()>
|
<tr key={user.id.clone()}>
|
||||||
<td><Link route=AppRoute::UserDetails(user.id.clone())>{&user.id}</Link></td>
|
<td><Link to={AppRoute::UserDetails{user_id: user.id.clone()}}>{&user.id}</Link></td>
|
||||||
<td>{&user.email}</td>
|
<td>{&user.email}</td>
|
||||||
<td>{&user.display_name}</td>
|
<td>{&user.display_name}</td>
|
||||||
<td>{&user.first_name}</td>
|
<td>{&user.first_name}</td>
|
||||||
<td>{&user.last_name}</td>
|
<td>{&user.last_name}</td>
|
||||||
<td>{&user.creation_date.date().naive_local()}</td>
|
<td>{&user.creation_date.naive_local().date()}</td>
|
||||||
<td>
|
<td>
|
||||||
<DeleteUser
|
<DeleteUser
|
||||||
username=user.id.clone()
|
username={user.id.clone()}
|
||||||
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
on_user_deleted={link.callback(Msg::OnUserDeleted)}
|
||||||
on_error=self.common.callback(Msg::OnError)/>
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,136 +1,93 @@
|
|||||||
use super::cookies::set_cookie;
|
use super::cookies::set_cookie;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use gloo_net::http::{Method, Request};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{login, registration, JWTClaims};
|
use lldap_auth::{login, registration, JWTClaims};
|
||||||
|
|
||||||
use yew::callback::Callback;
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use yew::format::Json;
|
use web_sys::RequestCredentials;
|
||||||
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct HostService {}
|
pub struct HostService {}
|
||||||
|
|
||||||
fn get_default_options() -> FetchOptions {
|
|
||||||
FetchOptions {
|
|
||||||
credentials: Some(Credentials::SameOrigin),
|
|
||||||
..FetchOptions::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||||
use jwt::*;
|
use jwt::*;
|
||||||
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
||||||
Ok(token.claims().clone())
|
Ok(token.claims().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_handler<Resp, CallbackResult, F>(
|
enum RequestType<Body: Serialize> {
|
||||||
callback: Callback<Result<CallbackResult>>,
|
Get,
|
||||||
handler: F,
|
Post(Body),
|
||||||
) -> Callback<Response<Result<Resp>>>
|
|
||||||
where
|
|
||||||
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
|
|
||||||
CallbackResult: 'static,
|
|
||||||
{
|
|
||||||
Callback::once(move |response: Response<Result<Resp>>| {
|
|
||||||
let (meta, maybe_data) = response.into_parts();
|
|
||||||
let message = maybe_data
|
|
||||||
.context("Could not reach server")
|
|
||||||
.and_then(|data| handler(meta.status, data));
|
|
||||||
callback.emit(message)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RequestBody<T>(T);
|
const GET_REQUEST: RequestType<()> = RequestType::Get;
|
||||||
|
|
||||||
impl<'a, R> From<&'a R> for RequestBody<Json<&'a R>>
|
fn base_url() -> String {
|
||||||
where
|
yew_router::utils::base_url().unwrap_or_default()
|
||||||
R: serde::ser::Serialize,
|
}
|
||||||
{
|
|
||||||
fn from(request: &'a R) -> Self {
|
async fn call_server<Body: Serialize>(
|
||||||
Self(Json(request))
|
url: &str,
|
||||||
|
body: RequestType<Body>,
|
||||||
|
error_message: &'static str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut request = Request::new(url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.credentials(RequestCredentials::SameOrigin);
|
||||||
|
if let RequestType::Post(b) = body {
|
||||||
|
request = request
|
||||||
|
.body(serde_json::to_string(&b)?)
|
||||||
|
.method(Method::POST);
|
||||||
|
}
|
||||||
|
let response = request.send().await?;
|
||||||
|
if response.ok() {
|
||||||
|
Ok(response.text().await?)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!(
|
||||||
|
"{}[{} {}]: {}",
|
||||||
|
error_message,
|
||||||
|
response.status(),
|
||||||
|
response.status_text(),
|
||||||
|
response.text().await?
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<yew::format::Nothing> for RequestBody<yew::format::Nothing> {
|
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||||
fn from(request: yew::format::Nothing) -> Self {
|
url: &str,
|
||||||
Self(request)
|
request: RequestType<Body>,
|
||||||
}
|
error_message: &'static str,
|
||||||
|
) -> Result<CallbackResult>
|
||||||
|
where
|
||||||
|
CallbackResult: DeserializeOwned + 'static,
|
||||||
|
{
|
||||||
|
let data = call_server(url, request, error_message).await?;
|
||||||
|
serde_json::from_str(&data).context("Could not parse response")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call_server<Req, CallbackResult, F, RB>(
|
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||||
url: &str,
|
url: &str,
|
||||||
request: RB,
|
request: RequestType<Body>,
|
||||||
callback: Callback<Result<CallbackResult>>,
|
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
parse_response: F,
|
) -> Result<()> {
|
||||||
) -> Result<FetchTask>
|
call_server(url, request, error_message).await.map(|_| ())
|
||||||
where
|
|
||||||
F: Fn(String) -> Result<CallbackResult> + 'static,
|
|
||||||
CallbackResult: 'static,
|
|
||||||
RB: Into<RequestBody<Req>>,
|
|
||||||
Req: Into<yew::format::Text>,
|
|
||||||
{
|
|
||||||
let request = {
|
|
||||||
// If the request type is empty (if the size is 0), it's a get.
|
|
||||||
if std::mem::size_of::<RB>() == 0 {
|
|
||||||
Request::get(url)
|
|
||||||
} else {
|
|
||||||
Request::post(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(request.into().0)?;
|
|
||||||
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
|
|
||||||
if status.is_success() {
|
|
||||||
parse_response(data)
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("{}[{}]: {}", error_message, status, data))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
FetchService::fetch_with_options(request, get_default_options(), handler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call_server_json_with_error_message<CallbackResult, RB, Req>(
|
fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, bool)> {
|
||||||
url: &str,
|
let jwt_claims = get_claims_from_jwt(response.token.as_str()).context("Could not parse JWT")?;
|
||||||
request: RB,
|
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||||
callback: Callback<Result<CallbackResult>>,
|
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||||
error_message: &'static str,
|
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||||
) -> Result<FetchTask>
|
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||||
where
|
.context("Error setting cookie")
|
||||||
CallbackResult: serde::de::DeserializeOwned + 'static,
|
|
||||||
RB: Into<RequestBody<Req>>,
|
|
||||||
Req: Into<yew::format::Text>,
|
|
||||||
{
|
|
||||||
call_server(url, request, callback, error_message, |data: String| {
|
|
||||||
serde_json::from_str(&data).context("Could not parse response")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call_server_empty_response_with_error_message<RB, Req>(
|
|
||||||
url: &str,
|
|
||||||
request: RB,
|
|
||||||
callback: Callback<Result<()>>,
|
|
||||||
error_message: &'static str,
|
|
||||||
) -> Result<FetchTask>
|
|
||||||
where
|
|
||||||
RB: Into<RequestBody<Req>>,
|
|
||||||
Req: Into<yew::format::Text>,
|
|
||||||
{
|
|
||||||
call_server(
|
|
||||||
url,
|
|
||||||
request,
|
|
||||||
callback,
|
|
||||||
error_message,
|
|
||||||
|_data: String| Ok(()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostService {
|
impl HostService {
|
||||||
pub fn graphql_query<QueryType>(
|
pub async fn graphql_query<QueryType>(
|
||||||
variables: QueryType::Variables,
|
variables: QueryType::Variables,
|
||||||
callback: Callback<Result<QueryType::ResponseData>>,
|
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<FetchTask>
|
) -> Result<QueryType::ResponseData>
|
||||||
where
|
where
|
||||||
QueryType: GraphQLQuery + 'static,
|
QueryType: GraphQLQuery + 'static,
|
||||||
{
|
{
|
||||||
@@ -147,113 +104,111 @@ impl HostService {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let parse_graphql_response = move |data: String| {
|
|
||||||
serde_json::from_str(&data)
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(unwrap_graphql_response)
|
|
||||||
};
|
|
||||||
let request_body = QueryType::build_query(variables);
|
let request_body = QueryType::build_query(variables);
|
||||||
call_server(
|
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||||
"/api/graphql",
|
&(base_url() + "/api/graphql"),
|
||||||
&request_body,
|
RequestType::Post(request_body),
|
||||||
callback,
|
|
||||||
error_message,
|
error_message,
|
||||||
parse_graphql_response,
|
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
.and_then(unwrap_graphql_response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login_start(
|
pub async fn login_start(
|
||||||
request: login::ClientLoginStartRequest,
|
request: login::ClientLoginStartRequest,
|
||||||
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>,
|
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
"/auth/opaque/login/start",
|
&(base_url() + "/auth/opaque/login/start"),
|
||||||
&request,
|
RequestType::Post(request),
|
||||||
callback,
|
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login_finish(
|
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||||
request: login::ClientLoginFinishRequest,
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
callback: Callback<Result<(String, bool)>>,
|
&(base_url() + "/auth/opaque/login/finish"),
|
||||||
) -> Result<FetchTask> {
|
RequestType::Post(request),
|
||||||
let set_cookies = |jwt_claims: JWTClaims| {
|
|
||||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
|
||||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
|
||||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
|
||||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
|
||||||
.context("Error clearing cookie")
|
|
||||||
};
|
|
||||||
let parse_token = move |data: String| {
|
|
||||||
get_claims_from_jwt(&data)
|
|
||||||
.context("Could not parse response")
|
|
||||||
.and_then(set_cookies)
|
|
||||||
};
|
|
||||||
call_server(
|
|
||||||
"/auth/opaque/login/finish",
|
|
||||||
&request,
|
|
||||||
callback,
|
|
||||||
"Could not finish authentication",
|
"Could not finish authentication",
|
||||||
parse_token,
|
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
.and_then(set_cookies_from_jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_start(
|
pub async fn register_start(
|
||||||
request: registration::ClientRegistrationStartRequest,
|
request: registration::ClientRegistrationStartRequest,
|
||||||
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
|
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
"/auth/opaque/register/start",
|
&(base_url() + "/auth/opaque/register/start"),
|
||||||
&request,
|
RequestType::Post(request),
|
||||||
callback,
|
|
||||||
"Could not start registration: ",
|
"Could not start registration: ",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_finish(
|
pub async fn register_finish(
|
||||||
request: registration::ClientRegistrationFinishRequest,
|
request: registration::ClientRegistrationFinishRequest,
|
||||||
callback: Callback<Result<()>>,
|
) -> Result<()> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
"/auth/opaque/register/finish",
|
&(base_url() + "/auth/opaque/register/finish"),
|
||||||
&request,
|
RequestType::Post(request),
|
||||||
callback,
|
|
||||||
"Could not finish registration",
|
"Could not finish registration",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh() -> Result<(String, bool)> {
|
||||||
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
|
&(base_url() + "/auth/refresh"),
|
||||||
|
GET_REQUEST,
|
||||||
|
"Could not start authentication: ",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.and_then(set_cookies_from_jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The `_request` parameter is to make it the same shape as the other functions.
|
// The `_request` parameter is to make it the same shape as the other functions.
|
||||||
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
|
pub async fn logout() -> Result<()> {
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
"/auth/logout",
|
&(base_url() + "/auth/logout"),
|
||||||
yew::format::Nothing,
|
GET_REQUEST,
|
||||||
callback,
|
|
||||||
"Could not logout",
|
"Could not logout",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_password_step1(
|
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||||
username: &str,
|
|
||||||
callback: Callback<Result<()>>,
|
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
&format!("/auth/reset/step1/{}", username),
|
&format!(
|
||||||
yew::format::Nothing,
|
"{}/auth/reset/step1/{}",
|
||||||
callback,
|
base_url(),
|
||||||
|
url_escape::encode_query(&username)
|
||||||
|
),
|
||||||
|
RequestType::Post(""),
|
||||||
"Could not initiate password reset",
|
"Could not initiate password reset",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_password_step2(
|
pub async fn reset_password_step2(
|
||||||
token: &str,
|
token: String,
|
||||||
callback: Callback<Result<String>>,
|
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||||
) -> Result<FetchTask> {
|
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&format!("/auth/reset/step2/{}", token),
|
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
||||||
yew::format::Nothing,
|
GET_REQUEST,
|
||||||
callback,
|
|
||||||
"Could not validate token",
|
"Could not validate token",
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn probe_password_reset() -> Result<bool> {
|
||||||
|
Ok(gloo_net::http::Request::get(
|
||||||
|
&(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
|
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
||||||
//! take care of error and task handling.
|
//! take care of error and task handling.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
future::Future,
|
||||||
|
marker::PhantomData,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::infra::api::HostService;
|
use crate::infra::api::HostService;
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
|
use gloo_console::error;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use yew::{
|
use yew::prelude::*;
|
||||||
prelude::*,
|
|
||||||
services::{fetch::FetchTask, ConsoleService},
|
|
||||||
};
|
|
||||||
use yewtil::NeqAssign;
|
|
||||||
|
|
||||||
/// Trait required for common components.
|
/// Trait required for common components.
|
||||||
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||||
/// Handle the incoming message. If an error is returned here, any running task will be
|
/// Handle the incoming message. If an error is returned here, any running task will be
|
||||||
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
||||||
/// component will be refreshed.
|
/// component will be refreshed.
|
||||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool>;
|
||||||
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
||||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||||
}
|
}
|
||||||
@@ -43,41 +50,33 @@ pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
|||||||
/// Structure that contains the common parts needed by most components.
|
/// Structure that contains the common parts needed by most components.
|
||||||
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
||||||
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
||||||
link: ComponentLink<C>,
|
|
||||||
pub props: <C as Component>::Properties,
|
|
||||||
pub error: Option<Error>,
|
pub error: Option<Error>,
|
||||||
task: Option<FetchTask>,
|
is_task_running: Arc<Mutex<bool>>,
|
||||||
|
_phantom: PhantomData<C>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||||
|
pub fn create() -> Self {
|
||||||
|
CommonComponentParts {
|
||||||
|
error: None,
|
||||||
|
is_task_running: Arc::new(Mutex::new(false)),
|
||||||
|
_phantom: PhantomData::<C>,
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Whether there is a currently running task in the background.
|
/// Whether there is a currently running task in the background.
|
||||||
pub fn is_task_running(&self) -> bool {
|
pub fn is_task_running(&self) -> bool {
|
||||||
self.task.is_some()
|
*self.is_task_running.lock().unwrap()
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel any background task.
|
|
||||||
pub fn cancel_task(&mut self) {
|
|
||||||
self.task = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
|
||||||
Self {
|
|
||||||
link,
|
|
||||||
props,
|
|
||||||
error: None,
|
|
||||||
task: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
||||||
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
||||||
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
|
pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
|
||||||
com.mut_common().error = None;
|
com.mut_common().error = None;
|
||||||
match com.handle_msg(msg) {
|
match com.handle_msg(ctx, msg) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
ConsoleService::error(&e.to_string());
|
error!(&e.to_string());
|
||||||
com.mut_common().error = Some(e);
|
com.mut_common().error = Some(e);
|
||||||
com.mut_common().cancel_task();
|
assert!(!*com.mut_common().is_task_running.lock().unwrap());
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
@@ -87,10 +86,11 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
/// Same as above, but the resulting error is instead passed to the reporting function.
|
/// Same as above, but the resulting error is instead passed to the reporting function.
|
||||||
pub fn update_and_report_error(
|
pub fn update_and_report_error(
|
||||||
com: &mut C,
|
com: &mut C,
|
||||||
|
ctx: &Context<C>,
|
||||||
msg: <C as Component>::Message,
|
msg: <C as Component>::Message,
|
||||||
report_fn: Callback<Error>,
|
report_fn: Callback<Error>,
|
||||||
) -> ShouldRender {
|
) -> bool {
|
||||||
let should_render = Self::update(com, msg);
|
let should_render = Self::update(com, ctx, msg);
|
||||||
com.mut_common()
|
com.mut_common()
|
||||||
.error
|
.error
|
||||||
.take()
|
.take()
|
||||||
@@ -101,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
.unwrap_or(should_render)
|
.unwrap_or(should_render)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
|
|
||||||
/// properties have changed and return whether the component should update.
|
|
||||||
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
|
|
||||||
where
|
|
||||||
<C as yew::Component>::Properties: std::cmp::PartialEq,
|
|
||||||
{
|
|
||||||
self.props.neq_assign(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a callback from the link.
|
|
||||||
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
|
|
||||||
where
|
|
||||||
M: Into<C::Message>,
|
|
||||||
F: Fn(IN) -> M + 'static,
|
|
||||||
{
|
|
||||||
self.link.callback(function)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
||||||
/// result. Returns whether _starting the call_ failed.
|
/// result.
|
||||||
pub fn call_backend<M, Req, Cb, Resp>(
|
pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
|
||||||
&mut self,
|
|
||||||
method: M,
|
|
||||||
req: Req,
|
|
||||||
callback: Cb,
|
|
||||||
) -> Result<()>
|
|
||||||
where
|
where
|
||||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
Fut: Future<Output = Resp> + 'static,
|
||||||
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
||||||
{
|
{
|
||||||
self.task = 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.
|
/// Call the backend with a GraphQL query.
|
||||||
@@ -140,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
/// `EnumCallback` should usually be left as `_`.
|
/// `EnumCallback` should usually be left as `_`.
|
||||||
pub fn call_graphql<QueryType, EnumCallback>(
|
pub fn call_graphql<QueryType, EnumCallback>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
ctx: &Context<C>,
|
||||||
variables: QueryType::Variables,
|
variables: QueryType::Variables,
|
||||||
enum_callback: EnumCallback,
|
enum_callback: EnumCallback,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
@@ -147,29 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
|||||||
QueryType: GraphQLQuery + 'static,
|
QueryType: GraphQLQuery + 'static,
|
||||||
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
||||||
{
|
{
|
||||||
self.task = HostService::graphql_query::<QueryType>(
|
self.call_backend(
|
||||||
variables,
|
ctx,
|
||||||
self.link.callback(enum_callback),
|
HostService::graphql_query::<QueryType>(variables, error_message),
|
||||||
error_message,
|
enum_callback,
|
||||||
)
|
);
|
||||||
.map_err::<(), _>(|e| {
|
|
||||||
ConsoleService::log(&e.to_string());
|
|
||||||
self.error = Some(e);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ use web_sys::HtmlDocument;
|
|||||||
|
|
||||||
fn get_document() -> Result<HtmlDocument> {
|
fn get_document() -> Result<HtmlDocument> {
|
||||||
web_sys::window()
|
web_sys::window()
|
||||||
.map(|w| w.document())
|
.and_then(|w| w.document())
|
||||||
.flatten()
|
|
||||||
.ok_or_else(|| anyhow!("Could not get window document"))
|
.ok_or_else(|| anyhow!("Could not get window document"))
|
||||||
.and_then(|d| {
|
.and_then(|d| {
|
||||||
d.dyn_into::<web_sys::HtmlDocument>()
|
d.dyn_into::<web_sys::HtmlDocument>()
|
||||||
@@ -16,18 +15,18 @@ fn get_document() -> Result<HtmlDocument> {
|
|||||||
|
|
||||||
pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) -> Result<()> {
|
pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) -> Result<()> {
|
||||||
let doc = web_sys::window()
|
let doc = web_sys::window()
|
||||||
.map(|w| w.document())
|
.and_then(|w| w.document())
|
||||||
.flatten()
|
|
||||||
.ok_or_else(|| anyhow!("Could not get window document"))
|
.ok_or_else(|| anyhow!("Could not get window document"))
|
||||||
.and_then(|d| {
|
.and_then(|d| {
|
||||||
d.dyn_into::<web_sys::HtmlDocument>()
|
d.dyn_into::<web_sys::HtmlDocument>()
|
||||||
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
|
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
|
||||||
})?;
|
})?;
|
||||||
let cookie_string = format!(
|
let cookie_string = format!(
|
||||||
"{}={}; expires={}; sameSite=Strict; path=/",
|
"{}={}; expires={}; sameSite=Strict; path={}/",
|
||||||
cookie_name,
|
cookie_name,
|
||||||
value,
|
value,
|
||||||
expiration.to_rfc2822()
|
expiration.to_rfc2822(),
|
||||||
|
yew_router::utils::base_url().unwrap_or_default()
|
||||||
);
|
);
|
||||||
doc.set_cookie(&cookie_string)
|
doc.set_cookie(&cookie_string)
|
||||||
.map_err(|_| anyhow!("Could not set cookie"))
|
.map_err(|_| anyhow!("Could not set cookie"))
|
||||||
@@ -55,7 +54,11 @@ pub fn get_cookie(cookie_name: &str) -> Result<Option<String>> {
|
|||||||
|
|
||||||
pub fn delete_cookie(cookie_name: &str) -> Result<()> {
|
pub fn delete_cookie(cookie_name: &str) -> Result<()> {
|
||||||
if get_cookie(cookie_name)?.is_some() {
|
if get_cookie(cookie_name)?.is_some() {
|
||||||
set_cookie(cookie_name, "", &Utc.ymd(1970, 1, 1).and_hms(0, 0, 0))
|
set_cookie(
|
||||||
|
cookie_name,
|
||||||
|
"",
|
||||||
|
&Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
52
app/src/infra/functional.rs
Normal file
52
app/src/infra/functional.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use crate::infra::api::HostService;
|
||||||
|
use anyhow::Result;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
use yew::{use_effect, 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>::ResponseData: std::cmp::PartialEq,
|
||||||
|
{
|
||||||
|
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||||
|
use_state_eq(|| LoadableResult::Loading);
|
||||||
|
{
|
||||||
|
let loadable_result = loadable_result.clone();
|
||||||
|
use_effect(move || {
|
||||||
|
let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query");
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = task.await;
|
||||||
|
loadable_result.set(LoadableResult::Loaded(response));
|
||||||
|
});
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
loadable_result.clone()
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod common_component;
|
pub mod common_component;
|
||||||
pub mod cookies;
|
pub mod cookies;
|
||||||
|
pub mod functional;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
pub mod schema;
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
#[wasm_bindgen(module = "bootstrap")]
|
#[wasm_bindgen]
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||||
pub type Modal;
|
pub type Modal;
|
||||||
|
|
||||||
#[wasm_bindgen(constructor)]
|
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||||
pub fn new(e: web_sys::Element) -> Modal;
|
pub fn new(e: web_sys::Element) -> Modal;
|
||||||
|
|
||||||
#[wasm_bindgen(method)]
|
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||||
pub fn show(this: &Modal);
|
pub fn show(this: &Modal);
|
||||||
|
|
||||||
#[wasm_bindgen(method)]
|
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||||
pub fn hide(this: &Modal);
|
pub fn hide(this: &Modal);
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/src/infra/schema.rs
Normal file
66
app/src/infra/schema.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use std::{fmt::Display, str::FromStr};
|
||||||
|
use validator::ValidationError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AttributeType {
|
||||||
|
String,
|
||||||
|
Integer,
|
||||||
|
DateTime,
|
||||||
|
Jpeg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for AttributeType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{:?}", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for AttributeType {
|
||||||
|
type Err = ();
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"String" => Ok(AttributeType::String),
|
||||||
|
"Integer" => Ok(AttributeType::Integer),
|
||||||
|
"DateTime" => Ok(AttributeType::DateTime),
|
||||||
|
"Jpeg" => Ok(AttributeType::Jpeg),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Macro to generate traits for converting between AttributeType and the
|
||||||
|
// graphql generated equivalents.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! convert_attribute_type {
|
||||||
|
($source_type:ty) => {
|
||||||
|
impl From<$source_type> for AttributeType {
|
||||||
|
fn from(value: $source_type) -> Self {
|
||||||
|
match value {
|
||||||
|
<$source_type>::STRING => AttributeType::String,
|
||||||
|
<$source_type>::INTEGER => AttributeType::Integer,
|
||||||
|
<$source_type>::DATE_TIME => AttributeType::DateTime,
|
||||||
|
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
|
||||||
|
_ => panic!("Unknown attribute type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AttributeType> for $source_type {
|
||||||
|
fn from(value: AttributeType) -> Self {
|
||||||
|
match value {
|
||||||
|
AttributeType::String => <$source_type>::STRING,
|
||||||
|
AttributeType::Integer => <$source_type>::INTEGER,
|
||||||
|
AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||||
|
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
||||||
|
AttributeType::from_str(attribute_type)
|
||||||
|
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
#![forbid(non_ascii_idents)]
|
#![forbid(non_ascii_idents)]
|
||||||
#![allow(clippy::nonstandard_macro_braces)]
|
#![allow(clippy::uninlined_format_args)]
|
||||||
|
#![allow(clippy::let_unit_value)]
|
||||||
|
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
|
|
||||||
@@ -8,7 +10,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
|||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn run_app() -> Result<(), JsValue> {
|
pub fn run_app() -> Result<(), JsValue> {
|
||||||
yew::start_app::<components::app::App>();
|
yew::start_app::<components::app::AppContainer>();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/static/fonts.css
Normal file
18
app/static/fonts.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Bebas Neue';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/JTUSjIg69CK48gW7PXoo9Wdhyzbi.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Bebas Neue';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/JTUSjIg69CK48gW7PXoo9Wlhyw.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
3
app/static/fonts/fonts.txt
Normal file
3
app/static/fonts/fonts.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/fonts/bootstrap-icons.woff2
|
||||||
|
https://fonts.gstatic.com/s/bebasneue/v2/JTUSjIg69CK48gW7PXoo9Wdhyzbi.woff2
|
||||||
|
https://fonts.gstatic.com/s/bebasneue/v2/JTUSjIg69CK48gW7PXoo9Wlhyw.woff2
|
||||||
5
app/static/libraries.txt
Normal file
5
app/static/libraries.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
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()
|
||||||
BIN
app/static/spinner.gif
Normal file
BIN
app/static/spinner.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
32
app/static/style.css
Normal file
32
app/static/style.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
header h2 {
|
||||||
|
font-family: 'Bebas Neue', cursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table>tbody {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table>tbody a {
|
||||||
|
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,12 +0,0 @@
|
|||||||
header h1 {
|
|
||||||
font-family: 'Bebas Neue', cursive;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table>tbody {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table>tbody a {
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lldap_auth"
|
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||||
version = "0.2.0"
|
description = "Authentication protocol for LLDAP"
|
||||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
homepage = "https://github.com/lldap/lldap"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
name = "lldap_auth"
|
||||||
|
repository = "https://github.com/lldap/lldap"
|
||||||
|
version = "0.4.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["opaque_server", "opaque_client"]
|
default = ["opaque_server", "opaque_client"]
|
||||||
opaque_server = []
|
opaque_server = []
|
||||||
opaque_client = []
|
opaque_client = []
|
||||||
js = []
|
js = []
|
||||||
|
sea_orm = ["dep:sea-orm"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust-argon2 = "0.8"
|
rust-argon2 = "0.8"
|
||||||
curve25519-dalek = "3"
|
curve25519-dalek = "3"
|
||||||
digest = "*"
|
digest = "0.9"
|
||||||
generic-array = "*"
|
generic-array = "0.14"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = "*"
|
serde = "*"
|
||||||
sha2 = "0.9"
|
sha2 = "0.9"
|
||||||
@@ -27,10 +32,16 @@ version = "0.6"
|
|||||||
version = "*"
|
version = "*"
|
||||||
features = [ "serde" ]
|
features = [ "serde" ]
|
||||||
|
|
||||||
|
[dependencies.sea-orm]
|
||||||
|
version= "0.12"
|
||||||
|
default-features = false
|
||||||
|
features = ["macros"]
|
||||||
|
optional = true
|
||||||
|
|
||||||
# For WASM targets, use the JS getrandom.
|
# For WASM targets, use the JS getrandom.
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||||
version = "0.2"
|
version = "0.2"
|
||||||
features = ["js"]
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
||||||
version = "0.2"
|
version = "0.2"
|
||||||
|
features = ["js"]
|
||||||
|
|||||||
144
auth/src/lib.rs
144
auth/src/lib.rs
@@ -3,22 +3,23 @@
|
|||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
pub mod opaque;
|
pub mod opaque;
|
||||||
|
|
||||||
/// The messages for the 3-step OPAQUE login process.
|
/// The messages for the 3-step OPAQUE and simple login process.
|
||||||
pub mod login {
|
pub mod login {
|
||||||
use super::*;
|
use super::{types::UserId, *};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ServerData {
|
pub struct ServerData {
|
||||||
pub username: String,
|
pub username: UserId,
|
||||||
pub server_login: opaque::server::login::ServerLogin,
|
pub server_login: opaque::server::login::ServerLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ClientLoginStartRequest {
|
pub struct ClientLoginStartRequest {
|
||||||
pub username: String,
|
pub username: UserId,
|
||||||
pub login_start_request: opaque::server::login::CredentialRequest,
|
pub login_start_request: opaque::server::login::CredentialRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,21 +36,43 @@ pub mod login {
|
|||||||
pub server_data: String,
|
pub server_data: String,
|
||||||
pub credential_finalization: opaque::client::login::CredentialFinalization,
|
pub credential_finalization: opaque::client::login::CredentialFinalization,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ClientSimpleLoginRequest {
|
||||||
|
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.as_str())
|
||||||
|
.field("password", &"***********")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ServerLoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
#[serde(rename = "refreshToken", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The messages for the 3-step OPAQUE registration process.
|
/// The messages for the 3-step OPAQUE registration process.
|
||||||
/// It is used to reset a user's password.
|
/// It is used to reset a user's password.
|
||||||
pub mod registration {
|
pub mod registration {
|
||||||
use super::*;
|
use super::{types::UserId, *};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ServerData {
|
pub struct ServerData {
|
||||||
pub username: String,
|
pub username: UserId,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct ClientRegistrationStartRequest {
|
pub struct ClientRegistrationStartRequest {
|
||||||
pub username: String,
|
pub username: UserId,
|
||||||
pub registration_start_request: opaque::server::registration::RegistrationRequest,
|
pub registration_start_request: opaque::server::registration::RegistrationRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +91,113 @@ pub mod registration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The messages for the 3-step OPAQUE registration process.
|
||||||
|
/// It is used to reset a user's password.
|
||||||
|
pub mod password_reset {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ServerPasswordResetResponse {
|
||||||
|
#[serde(rename = "userId")]
|
||||||
|
pub user_id: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod types {
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[cfg(feature = "sea_orm")]
|
||||||
|
use sea_orm::{DbErr, DeriveValueType, QueryResult, 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, Debug, Default, Hash, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||||
|
#[serde(from = "CaseInsensitiveString")]
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::fmt::Display for UserId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "sea_orm")]
|
||||||
|
impl From<&UserId> for Value {
|
||||||
|
fn from(user_id: &UserId) -> Self {
|
||||||
|
user_id.as_str().into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(feature = "sea_orm")]
|
||||||
|
impl TryFromU64 for UserId {
|
||||||
|
fn try_from_u64(_n: u64) -> Result<Self, DbErr> {
|
||||||
|
Err(DbErr::ConvertFromU64(
|
||||||
|
"UserId cannot be constructed from u64",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct JWTClaims {
|
pub struct JWTClaims {
|
||||||
pub exp: DateTime<Utc>,
|
pub exp: DateTime<Utc>,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::types::UserId;
|
||||||
use opaque_ke::ciphersuite::CipherSuite;
|
use opaque_ke::ciphersuite::CipherSuite;
|
||||||
use rand::{CryptoRng, RngCore};
|
use rand::{CryptoRng, RngCore};
|
||||||
|
|
||||||
@@ -77,10 +78,10 @@ pub mod client {
|
|||||||
pub use opaque_ke::ClientRegistrationFinishParameters;
|
pub use opaque_ke::ClientRegistrationFinishParameters;
|
||||||
/// Initiate the registration negotiation.
|
/// Initiate the registration negotiation.
|
||||||
pub fn start_registration<R: RngCore + CryptoRng>(
|
pub fn start_registration<R: RngCore + CryptoRng>(
|
||||||
password: &str,
|
password: &[u8],
|
||||||
rng: &mut R,
|
rng: &mut R,
|
||||||
) -> AuthenticationResult<ClientRegistrationStartResult> {
|
) -> AuthenticationResult<ClientRegistrationStartResult> {
|
||||||
Ok(ClientRegistration::start(rng, password.as_bytes())?)
|
Ok(ClientRegistration::start(rng, password)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finalize the registration negotiation.
|
/// Finalize the registration negotiation.
|
||||||
@@ -145,12 +146,12 @@ pub mod server {
|
|||||||
pub fn start_registration(
|
pub fn start_registration(
|
||||||
server_setup: &ServerSetup,
|
server_setup: &ServerSetup,
|
||||||
registration_request: RegistrationRequest,
|
registration_request: RegistrationRequest,
|
||||||
username: &str,
|
username: &UserId,
|
||||||
) -> AuthenticationResult<ServerRegistrationStartResult> {
|
) -> AuthenticationResult<ServerRegistrationStartResult> {
|
||||||
Ok(ServerRegistration::start(
|
Ok(ServerRegistration::start(
|
||||||
server_setup,
|
server_setup,
|
||||||
registration_request,
|
registration_request,
|
||||||
username.as_bytes(),
|
username.as_str().as_bytes(),
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,14 +179,14 @@ pub mod server {
|
|||||||
server_setup: &ServerSetup,
|
server_setup: &ServerSetup,
|
||||||
password_file: Option<ServerRegistration>,
|
password_file: Option<ServerRegistration>,
|
||||||
credential_request: CredentialRequest,
|
credential_request: CredentialRequest,
|
||||||
username: &str,
|
username: &UserId,
|
||||||
) -> AuthenticationResult<ServerLoginStartResult> {
|
) -> AuthenticationResult<ServerLoginStartResult> {
|
||||||
Ok(ServerLogin::start(
|
Ok(ServerLogin::start(
|
||||||
rng,
|
rng,
|
||||||
server_setup,
|
server_setup,
|
||||||
password_file,
|
password_file,
|
||||||
credential_request,
|
credential_request,
|
||||||
username.as_bytes(),
|
username.as_str().as_bytes(),
|
||||||
ServerLoginStartParameters::default(),
|
ServerLoginStartParameters::default(),
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|||||||
20
docker-entrypoint-rootless.sh
Executable file
20
docker-entrypoint-rootless.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG_FILE=/data/lldap_config.toml
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
echo "[entrypoint] Copying the default config to $CONFIG_FILE"
|
||||||
|
echo "[entrypoint] Edit this $CONFIG_FILE to configure LLDAP."
|
||||||
|
if cp /app/lldap_config.docker_template.toml $CONFIG_FILE; then
|
||||||
|
echo "Configuration copied successfully."
|
||||||
|
else
|
||||||
|
echo "Fail to copy configuration, check permission on /data or manually create one by copying from LLDAP repository"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "> Starting lldap.."
|
||||||
|
echo ""
|
||||||
|
exec /app/lldap "$@"
|
||||||
|
exec "$@"
|
||||||
@@ -1,20 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
for SECRET in LLDAP_JWT_SECRET LLDAP_LDAP_USER_PASS; do
|
|
||||||
FILE_VAR="${SECRET}_FILE"
|
|
||||||
SECRET_FILE="${!FILE_VAR:-}"
|
|
||||||
if [[ -n "$SECRET_FILE" ]]; then
|
|
||||||
if [[ -f "$SECRET_FILE" ]]; then
|
|
||||||
declare "$SECRET=$(cat $SECRET_FILE)"
|
|
||||||
export "$SECRET"
|
|
||||||
echo "[entrypoint] Set $SECRET from $SECRET_FILE"
|
|
||||||
else
|
|
||||||
echo "[entrypoint] Could not read contents of $SECRET_FILE (specified in $FILE_VAR)" >&2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
CONFIG_FILE=/data/lldap_config.toml
|
CONFIG_FILE=/data/lldap_config.toml
|
||||||
|
|
||||||
if [[ ( ! -w "/data" ) ]] || [[ ( ! -d "/data" ) ]]; then
|
if [[ ( ! -w "/data" ) ]] || [[ ( ! -d "/data" ) ]]; then
|
||||||
@@ -35,4 +21,13 @@ if [[ ! -r "$CONFIG_FILE" ]]; then
|
|||||||
exit 1;
|
exit 1;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec /app/lldap "$@"
|
echo "> Setup permissions.."
|
||||||
|
find /app \! -user "$UID" -exec chown "$UID:$GID" '{}' +
|
||||||
|
find /data \! -user "$UID" -exec chown "$UID:$GID" '{}' +
|
||||||
|
|
||||||
|
|
||||||
|
echo "> Starting lldap.."
|
||||||
|
echo ""
|
||||||
|
exec gosu "$UID:$GID" /app/lldap "$@"
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user