Compare commits
329 Commits
v0.4.1
...
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 |
2
.config/nextest.toml
Normal file
2
.config/nextest.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[profile.default]
|
||||
fail-fast = false
|
||||
26
.devcontainer/Dockerfile
Normal file
26
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM rust:1.74
|
||||
|
||||
ARG USERNAME=lldapdev
|
||||
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
||||
# See https://github.com/actions/checkout/issues/956.
|
||||
ARG USER_UID=1001
|
||||
ARG USER_GID=$USER_UID
|
||||
|
||||
# Create the user
|
||||
RUN groupadd --gid $USER_GID $USERNAME \
|
||||
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y sudo \
|
||||
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
|
||||
&& chmod 0440 /etc/sudoers.d/$USERNAME
|
||||
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends libssl-dev musl-dev make perl curl gzip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
|
||||
&& rustup target add wasm32-unknown-unknown
|
||||
|
||||
USER $USERNAME
|
||||
ENV CARGO_HOME=/home/$USERNAME/.cargo
|
||||
ENV SHELL=/bin/bash
|
||||
8
.devcontainer/devcontainer.json
Normal file
8
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "LLDAP dev",
|
||||
"build": { "dockerfile": "Dockerfile" },
|
||||
"forwardPorts": [
|
||||
3890,
|
||||
17170
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
.git/*
|
||||
.github/*
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Don't track cargo generated files
|
||||
target/*
|
||||
@@ -17,6 +18,7 @@ Dockerfile
|
||||
*.md
|
||||
LICENSE
|
||||
CHANGELOG.md
|
||||
README.md
|
||||
docs/*
|
||||
example_configs/*
|
||||
|
||||
@@ -28,12 +30,24 @@ package.json
|
||||
# Pre-build binaries
|
||||
*.tar.gz
|
||||
|
||||
# VSCode dirs
|
||||
.vscode
|
||||
.devcontainer
|
||||
|
||||
# Created databases
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# Various config files that shouldn't be tracked
|
||||
.env
|
||||
lldap_config.toml
|
||||
server_key
|
||||
users.db*
|
||||
screenshot.png
|
||||
recipe.json
|
||||
lldap_config.toml
|
||||
cert.pem
|
||||
key.pem
|
||||
|
||||
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
example_configs/** linguist-documentation
|
||||
docs/** linguist-documentation
|
||||
*.md linguist-documentation
|
||||
lldap_config.docker_template.toml linguist-documentation
|
||||
|
||||
schema.graphql linguist-generated
|
||||
|
||||
.github/** -linguist-detectable
|
||||
.devcontainer/** -linguist-detectable
|
||||
.config/** -linguist-detectable
|
||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @nitnelave
|
||||
5
.github/FUNDING.yml
vendored
Normal file
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [lldap]
|
||||
|
||||
custom: ['https://bmc.link/nitnelave']
|
||||
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Logs**
|
||||
If applicable, add logs to explain the problem.
|
||||
LLDAP should be started in verbose mode (`LLDAP_VERBOSE=true` env variable, or `verbose = true` in the config). Include the logs in triple-backtick "```"
|
||||
If integrating with another service, please add its configuration (paste it or screenshot it) as well as any useful logs or screenshots (showing the error, for instance).
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE REQUEST]"
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Motivation**
|
||||
Why do you want the feature? What problem do you have, what use cases would it enable?
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered. You can include workarounds that are currently possible.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
25
.github/ISSUE_TEMPLATE/integration-request.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/integration-request.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Integration request
|
||||
about: Request for integration with a service
|
||||
title: "[INTEGRATION]"
|
||||
labels: integration
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] Check if there is already an [example config](https://github.com/lldap/lldap/tree/main/example_configs) for it.
|
||||
- [ ] Try to figure out the configuration values for the new service yourself.
|
||||
- You can use other example configs for inspiration.
|
||||
- If you're having trouble, you can ask on [Discord](https://discord.gg/h5PEdRMNyP) or create an issue.
|
||||
- If you succeed, make sure to contribute an example configuration, or a configuration guide.
|
||||
- If you hit a block because of an unimplemented feature, create an issue.
|
||||
|
||||
**Description of the service**
|
||||
Quick summary of what the service is and how it's using LDAP. Link to the service's documentation on configuring LDAP.
|
||||
|
||||
**What you've tried**
|
||||
A sample configuration that you've tried.
|
||||
|
||||
**What's not working**
|
||||
Error logs, error screenshots, features that are not working, missing features.
|
||||
13
.github/codecov.yml
vendored
13
.github/codecov.yml
vendored
@@ -1,12 +1,23 @@
|
||||
codecov:
|
||||
require_ci_to_pass: yes
|
||||
comment:
|
||||
layout: "diff,flags"
|
||||
layout: "header,diff,files"
|
||||
require_changes: true
|
||||
require_base: true
|
||||
require_head: true
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: "75%"
|
||||
threshold: "0.1%"
|
||||
removed_code_behavior: adjust_base
|
||||
github_checks:
|
||||
annotations: true
|
||||
ignore:
|
||||
- "app"
|
||||
- "docs"
|
||||
- "example_configs"
|
||||
- "migration-tool"
|
||||
- "scripts"
|
||||
- "set-password"
|
||||
|
||||
86
.github/workflows/Dockerfile.ci.alpine
vendored
86
.github/workflows/Dockerfile.ci.alpine
vendored
@@ -1,65 +1,6 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/amd64-bin/lldap target/lldap && \
|
||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armhf-bin/lldap target/lldap && \
|
||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/migration-tool /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM alpine:3.16
|
||||
WORKDIR /app
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
ENV GOSU_VERSION 1.14
|
||||
# Fetch gosu from git
|
||||
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 \
|
||||
@@ -76,7 +17,7 @@ RUN set -eux; \
|
||||
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 || :; \
|
||||
gpgconf --kill all; \
|
||||
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
|
||||
\
|
||||
# clean up fetch dependencies
|
||||
@@ -86,21 +27,4 @@ RUN set -eux; \
|
||||
# verify that the binary works
|
||||
gosu --version; \
|
||||
gosu nobody true
|
||||
RUN apk add --no-cache tini ca-certificates bash && \
|
||||
addgroup -g $GID $USER && \
|
||||
adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
--home "$(pwd)" \
|
||||
--ingroup "$USER" \
|
||||
--no-create-home \
|
||||
--uid "$UID" \
|
||||
"$USER" && \
|
||||
mkdir -p /data && \
|
||||
chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
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
|
||||
102
.github/workflows/Dockerfile.ci.debian
vendored
102
.github/workflows/Dockerfile.ci.debian
vendored
@@ -1,71 +1,31 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/amd64-bin/lldap target/lldap && \
|
||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armhf-bin/lldap target/lldap && \
|
||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/migration-tool /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM debian:bullseye-slim
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini openssl ca-certificates gosu && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||
mkdir -p /data && chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
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
|
||||
50
.github/workflows/Dockerfile.dev
vendored
50
.github/workflows/Dockerfile.dev
vendored
@@ -1,34 +1,40 @@
|
||||
FROM rust:1.62-slim-bullseye
|
||||
# Keep tracking base image
|
||||
FROM rust:1.74-slim-bookworm
|
||||
|
||||
# Set needed env path
|
||||
ENV PATH="/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
|
||||
|
||||
### Install build deps x86_64
|
||||
# Set building env
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=armv7l-linux-musleabihf-gcc \
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc \
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
|
||||
CC_armv7_unknown_linux_musleabihf=armv7l-linux-musleabihf-gcc \
|
||||
CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc \
|
||||
CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc
|
||||
|
||||
### Install Additional Build Tools
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||
apt update && \
|
||||
apt install -y --no-install-recommends nodejs && \
|
||||
apt install -y --no-install-recommends curl git wget make perl pkg-config tar jq gzip && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
npm install -g npm && \
|
||||
npm install -g yarn && \
|
||||
npm install -g pnpm
|
||||
|
||||
### Install build deps aarch64 build
|
||||
RUN dpkg --add-architecture arm64 && \
|
||||
apt update && \
|
||||
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
### Add musl-gcc aarch64 and x86_64
|
||||
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 ./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"]
|
||||
|
||||
907
.github/workflows/docker-build-static.yml
vendored
907
.github/workflows/docker-build-static.yml
vendored
File diff suppressed because it is too large
Load Diff
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.
|
||||
31
.github/workflows/rust.yml
vendored
31
.github/workflows/rust.yml
vendored
@@ -13,7 +13,6 @@ jobs:
|
||||
pre_job:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
steps:
|
||||
@@ -22,7 +21,7 @@ jobs:
|
||||
with:
|
||||
concurrent_skipping: 'outdated_runs'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh"]'
|
||||
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".dockerignore", ".gitignore", "lldap_config.docker_template.toml", "Dockerfile"]'
|
||||
do_not_skip: '["workflow_dispatch", "schedule"]'
|
||||
cancel_others: true
|
||||
|
||||
@@ -34,8 +33,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.1.0
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
uses: actions/checkout@v4.1.1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
- name: Run tests
|
||||
@@ -53,9 +52,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.1.0
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
@@ -70,9 +69,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.1.0
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo fmt
|
||||
uses: actions-rs/cargo@v1
|
||||
@@ -82,19 +81,21 @@ jobs:
|
||||
|
||||
coverage:
|
||||
name: Code coverage
|
||||
needs: pre_job
|
||||
needs:
|
||||
- pre_job
|
||||
- test
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.1.0
|
||||
uses: actions/checkout@v4.1.1
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Generate code coverage for unit test
|
||||
run: cargo llvm-cov --workspace --no-report
|
||||
@@ -102,6 +103,14 @@ jobs:
|
||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
|
||||
with:
|
||||
files: lcov.info
|
||||
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 }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ server_key
|
||||
*.tar.gz
|
||||
|
||||
# Misc
|
||||
.vscode
|
||||
.env
|
||||
recipe.json
|
||||
lldap_config.toml
|
||||
|
||||
153
CHANGELOG.md
153
CHANGELOG.md
@@ -5,7 +5,158 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.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
|
||||
|
||||
|
||||
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.
|
||||
3202
Cargo.lock
generated
3202
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
21
Cargo.toml
@@ -3,12 +3,23 @@ members = [
|
||||
"server",
|
||||
"auth",
|
||||
"app",
|
||||
"migration-tool"
|
||||
"migration-tool",
|
||||
"set-password",
|
||||
]
|
||||
|
||||
default-members = ["server"]
|
||||
|
||||
# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged.
|
||||
[patch.crates-io.ldap3_proto]
|
||||
git = 'https://github.com/nitnelave/ldap3_server/'
|
||||
rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc'
|
||||
resolver = "2"
|
||||
|
||||
[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/'
|
||||
|
||||
47
Dockerfile
47
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Build image
|
||||
FROM rust:alpine3.14 AS chef
|
||||
FROM rust:alpine3.16 AS chef
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
@@ -11,7 +11,7 @@ RUN set -x \
|
||||
--uid 10001 \
|
||||
app \
|
||||
# Install required packages
|
||||
&& apk add npm openssl-dev musl-dev make perl curl
|
||||
&& apk add openssl-dev musl-dev make perl curl gzip
|
||||
|
||||
USER app
|
||||
WORKDIR /app
|
||||
@@ -19,7 +19,6 @@ WORKDIR /app
|
||||
RUN set -x \
|
||||
# Install build tools
|
||||
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
|
||||
&& npm install rollup \
|
||||
&& rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Prepare the dependency list.
|
||||
@@ -32,27 +31,58 @@ FROM chef AS builder
|
||||
COPY --from=planner /tmp/recipe.json recipe.json
|
||||
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||
&& cargo chef cook --release -p lldap \
|
||||
&& cargo chef cook --release -p migration-tool
|
||||
&& cargo chef cook --release -p lldap_migration_tool \
|
||||
&& cargo chef cook --release -p lldap_set_password
|
||||
|
||||
# Copy the source and build the app and server.
|
||||
COPY --chown=app:app . .
|
||||
RUN cargo build --release -p lldap -p migration-tool \
|
||||
RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password \
|
||||
# Build the frontend.
|
||||
&& ./app/build.sh
|
||||
|
||||
# Final image
|
||||
FROM alpine:3.14
|
||||
FROM alpine:3.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
|
||||
|
||||
COPY --from=builder /app/app/index_local.html app/index.html
|
||||
COPY --from=builder /app/app/static app/static
|
||||
COPY --from=builder /app/app/pkg app/pkg
|
||||
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool ./
|
||||
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||
|
||||
RUN set -x \
|
||||
&& apk add --no-cache bash \
|
||||
&& apk add --no-cache bash tzdata \
|
||||
&& for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
@@ -64,3 +94,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
|
||||
428
README.md
428
README.md
@@ -5,14 +5,15 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||
<a href="https://github.com/lldap/lldap/actions/workflows/rust.yml?query=branch%3Amain">
|
||||
<img
|
||||
src="https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg"
|
||||
src="https://github.com/lldap/lldap/actions/workflows/rust.yml/badge.svg"
|
||||
alt="Build"/>
|
||||
</a>
|
||||
<a href="https://discord.gg/h5PEdRMNyP">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
|
||||
</a>
|
||||
|
||||
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
|
||||
<img
|
||||
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
|
||||
@@ -23,25 +24,38 @@
|
||||
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
|
||||
alt="Unsafe forbidden"/>
|
||||
</a>
|
||||
<a href="https://app.codecov.io/gh/nitnelave/lldap">
|
||||
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/nitnelave/lldap" />
|
||||
<a href="https://app.codecov.io/gh/lldap/lldap">
|
||||
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/lldap/lldap" />
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://www.buymeacoffee.com/nitnelave" target="_blank">
|
||||
<img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" >
|
||||
</a>
|
||||
</p>
|
||||
|
||||
- [About](#About)
|
||||
- [Installation](#Installation)
|
||||
- [With Docker](#With-Docker)
|
||||
- [From source](#From-source)
|
||||
- [Cross-compilation](#Cross-compilation)
|
||||
- [Client configuration](#Client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Sample client configurations](#Sample-client-configurations)
|
||||
- [Comparisons with other services](#Comparisons-with-other-services)
|
||||
- [vs OpenLDAP](#vs-openldap)
|
||||
- [vs FreeIPA](#vs-freeipa)
|
||||
- [I can't log in!](#i-cant-log-in)
|
||||
- [Contributions](#Contributions)
|
||||
- [About](#about)
|
||||
- [Installation](#installation)
|
||||
- [With Docker](#with-docker)
|
||||
- [With Kubernetes](#with-kubernetes)
|
||||
- [From a package repository](#from-a-package-repository)
|
||||
- [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
|
||||
|
||||
@@ -51,7 +65,7 @@ many backends, from KeyCloak to Authelia to Nextcloud and
|
||||
[more](#compatible-services)!
|
||||
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
|
||||
src="https://raw.githubusercontent.com/lldap/lldap/master/screenshot.png"
|
||||
alt="Screenshot of the user list page"
|
||||
width="50%"
|
||||
align="right"
|
||||
@@ -62,10 +76,11 @@ edit their own details or reset their password by email.
|
||||
|
||||
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
||||
check out OpenLDAP. This server is a user management system that is:
|
||||
* simple to setup (no messing around with `slapd`),
|
||||
* simple to manage (friendly web UI),
|
||||
* low resources,
|
||||
* opinionated with basic defaults so you don't have to understand the
|
||||
|
||||
- simple to setup (no messing around with `slapd`),
|
||||
- simple to manage (friendly web UI),
|
||||
- low resources,
|
||||
- opinionated with basic defaults so you don't have to understand the
|
||||
subtleties of LDAP.
|
||||
|
||||
It mostly targets self-hosting servers, with open-source components like
|
||||
@@ -76,13 +91,17 @@ For more features (OAuth/OpenID support, reverse proxy, ...) you can install
|
||||
other components (KeyCloak, Authelia, ...) using this server as the source of
|
||||
truth for users, via LDAP.
|
||||
|
||||
By default, the data is stored in SQLite, but you can swap the backend with
|
||||
MySQL/MariaDB or PostgreSQL.
|
||||
|
||||
## Installation
|
||||
|
||||
### With Docker
|
||||
|
||||
The image is available at `nitnelave/lldap`. You should persist the `/data`
|
||||
folder, which contains your configuration, the database and the private key
|
||||
file.
|
||||
The image is available at `lldap/lldap`. You should persist the `/data`
|
||||
folder, which contains your configuration and the SQLite database (you can
|
||||
remove this step if you use a different DB and configure with environment
|
||||
variables only).
|
||||
|
||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||
@@ -90,18 +109,25 @@ Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
Environment variables should be prefixed with `LLDAP_` to override the
|
||||
configuration.
|
||||
|
||||
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use default one. The default admin password is `password`, you can change the password later using the web interface.
|
||||
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use
|
||||
default one. The default admin password is `password`, you can change the
|
||||
password later using the web interface.
|
||||
|
||||
Secrets can also be set through a file. The filename should be specified by the
|
||||
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_LDAP_USER_PASS_FILE`, and the file
|
||||
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_KEY_SEED_FILE`, and the file
|
||||
contents are loaded into the respective configuration parameters. Note that
|
||||
`_FILE` variables take precedence.
|
||||
|
||||
Example for docker compose for `:stable` tag:
|
||||
* When defined with `user: ##:##` , ensure `/data` directory had permission for the defined user, else `1000:1000` used.
|
||||
Example for docker compose:
|
||||
|
||||
- You can use either the `:latest` tag image or `:stable` as used in this example.
|
||||
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
|
||||
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
|
||||
- If no `TZ` is set, default `UTC` timezone will be used.
|
||||
- You can generate the secrets by running `./generate_secrets.sh`
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
version: "3"
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
@@ -109,41 +135,12 @@ volumes:
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/lldap:stable
|
||||
# Change this to the user:group you want.
|
||||
user: "33:33"
|
||||
image: lldap/lldap:stable
|
||||
ports:
|
||||
# For LDAP
|
||||
- "3890:3890"
|
||||
# For the web front-end
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
- "lldap_data:/data"
|
||||
# Alternatively, you can mount a local folder
|
||||
# - "./lldap_data:/data"
|
||||
environment:
|
||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
```
|
||||
|
||||
Example for docker compose for `:latest` tag:
|
||||
* `:latest` tag image contain recent pushed codes or feature test, breaks is expected.
|
||||
* If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/lldap:latest
|
||||
ports:
|
||||
# For LDAP
|
||||
- "3890:3890"
|
||||
# For LDAP, not recommended to expose, see Usage section.
|
||||
#- "3890:3890"
|
||||
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
|
||||
#- "6360:6360"
|
||||
# For the web front-end
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
@@ -153,38 +150,95 @@ services:
|
||||
environment:
|
||||
- UID=####
|
||||
- GID=####
|
||||
- TZ=####/####
|
||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LLDAP_KEY_SEED=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
# If using LDAPS, set enabled true and configure cert and key path
|
||||
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
||||
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
|
||||
# You can also set a different database:
|
||||
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||
```
|
||||
|
||||
Then the service will listen on two ports, one for LDAP and one for the web
|
||||
front-end.
|
||||
|
||||
### With Kubernetes
|
||||
|
||||
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
|
||||
|
||||
You can bootstrap your lldap instance (users, groups)
|
||||
using [bootstrap.sh](example_configs/bootstrap/bootstrap.md#kubernetes-job).
|
||||
It can be run by Argo CD for managing users in git-opt way, or as a one-shot job.
|
||||
|
||||
### From a package repository
|
||||
|
||||
**Do not open issues in this repository for problems with third-party
|
||||
pre-built packages. Report issues downstream.**
|
||||
|
||||
Depending on the distribution you use, it might be possible to install lldap
|
||||
from a package repository, officially supported by the distribution or
|
||||
community contributed.
|
||||
|
||||
#### 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
|
||||
|
||||
#### Backend
|
||||
|
||||
To compile the project, you'll need:
|
||||
|
||||
* npm, curl: `sudo apt install curl npm`
|
||||
* Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
||||
- 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 migration-tool
|
||||
cargo build --release -p lldap -p lldap_migration_tool
|
||||
```
|
||||
|
||||
The resulting binaries will be in `./target/release/`. Alternatively, you can
|
||||
just run `cargo run -- run` to run the server.
|
||||
|
||||
#### Frontend
|
||||
|
||||
To bring up the server, you'll need to compile the frontend. In addition to
|
||||
cargo, you'll need:
|
||||
`cargo`, you'll need WASM-pack, which can be installed by running `cargo install wasm-pack`.
|
||||
|
||||
* WASM-pack: `cargo install wasm-pack`
|
||||
* rollup.js: `npm install rollup`
|
||||
Then you can build the frontend files with
|
||||
|
||||
Then you can build the frontend files with `./app/build.sh` (you'll need to run
|
||||
this after every front-end change to update the WASM package served).
|
||||
```shell
|
||||
./app/build.sh
|
||||
```
|
||||
|
||||
(you'll need to run this after every front-end change to update the WASM
|
||||
package served).
|
||||
|
||||
The default config is in `src/infra/configuration.rs`, but you can override it
|
||||
by creating an `lldap_config.toml`, setting environment variables or passing
|
||||
@@ -216,6 +270,47 @@ You can then get the compiled server binary in
|
||||
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||
files in an `app` folder next to the binary).
|
||||
|
||||
## Usage
|
||||
|
||||
The simplest way to use LLDAP is through the web front-end. There you can
|
||||
create users, set passwords, add them to groups and so on. Users can also
|
||||
connect to the web UI and change their information, or request a password reset
|
||||
link (if you configured the SMTP client).
|
||||
|
||||
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
|
||||
|
||||
### Compatible services
|
||||
@@ -229,14 +324,15 @@ the config).
|
||||
### General configuration guide
|
||||
|
||||
To configure the services that will talk to LLDAP, here are the values:
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
- The LDAP password is from the configuration (same as to log in to the web
|
||||
UI).
|
||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
- The LDAP password is from the configuration (same as to log in to the web
|
||||
UI).
|
||||
- The users are all located in `ou=people,` + the base DN, so by default user
|
||||
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
|
||||
- Similarly, the groups are located in `ou=groups`, so the group `family`
|
||||
will be at `cn=family,ou=groups,dc=example,dc=com`.
|
||||
|
||||
Testing group membership through `memberOf` is supported, so you can have a
|
||||
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||
@@ -251,53 +347,113 @@ administration access to many services.
|
||||
Some specific clients have been tested to work and come with sample
|
||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||
folder for help with:
|
||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [Dokuwiki](example_configs/dokuwiki.md)
|
||||
- [Dolibarr](example_configs/dolibarr.md)
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Hedgedoc](example_configs/hedgedoc.md)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jitsi Meet](example_configs/jitsi_meet.conf)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Nextcloud](example_configs/nextcloud.md)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [WG Portal](example_configs/wg_portal.env.example)
|
||||
- [XBackBone](example_configs/xbackbone_config.php)
|
||||
|
||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Apereo CAS Server](example_configs/apereo_cas_server.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [Authentik](example_configs/authentik.md)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [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
|
||||
|
||||
### vs OpenLDAP
|
||||
|
||||
OpenLDAP is a monster of a service that implements all of LDAP and all of its
|
||||
extensions, plus some of its own. That said, if you need all that flexibility,
|
||||
it might be what you need! Note that installation can be a bit painful
|
||||
(figuring out how to use `slapd`) and people have mixed experiences following
|
||||
tutorials online. If you don't configure it properly, you might end up storing
|
||||
passwords in clear, so a breach of your server would reveal all the stored
|
||||
passwords!
|
||||
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
|
||||
all of LDAP and all of its extensions, plus some of its own. That said, if you
|
||||
need all that flexibility, it might be what you need! Note that installation
|
||||
can be a bit painful (figuring out how to use `slapd`) and people have mixed
|
||||
experiences following tutorials online. If you don't configure it properly, you
|
||||
might end up storing passwords in clear, so a breach of your server would
|
||||
reveal all the stored passwords!
|
||||
|
||||
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
|
||||
install one (not that many that look nice) and configure it.
|
||||
install one (not that many look nice) and configure it.
|
||||
|
||||
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
|
||||
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
|
||||
However, it's not as flexible as OpenLDAP.
|
||||
|
||||
### vs FreeIPA
|
||||
|
||||
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
|
||||
Samba, you name it, it has it. In addition to user management, it also does
|
||||
security policies, single sign-on, certificate management, linux account
|
||||
management and so on.
|
||||
[FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
|
||||
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||
management, it also does security policies, single sign-on, certificate
|
||||
management, linux account management and so on.
|
||||
|
||||
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||
more complex to maintain, though.
|
||||
@@ -306,25 +462,37 @@ LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
|
||||
configure (no messing around with DNS or security policies) and simpler to
|
||||
use. It also comes conveniently packed in a docker container.
|
||||
|
||||
### vs Kanidm
|
||||
|
||||
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
|
||||
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
|
||||
WebAuthn. It comes with a (read-only) LDAPS server.
|
||||
|
||||
It's fairly easy to install and does much more; but their LDAP server is
|
||||
read-only, and by having more moving parts it is inherently more complex. If
|
||||
you don't need to modify the users through LDAP and you're planning on
|
||||
installing something like [KeyCloak](https://www.keycloak.org) to provide
|
||||
modern identity protocols, check out Kanidm.
|
||||
|
||||
## I can't log in!
|
||||
|
||||
If you just set up the server, can get to the login page but the password you
|
||||
set isn't working, try the following:
|
||||
|
||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||
docker volume or mounted from the host filesystem.
|
||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||
or in the current directory). If there isn't, copy
|
||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||
(passwords, secrets, ...).
|
||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||
you specified the DB URL, which defaults to the current directory). If
|
||||
there isn't, check that the user running the command (user with ID 10001
|
||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
- (For docker): Make sure that the `/data` folder is persistent, either to a
|
||||
docker volume or mounted from the host filesystem.
|
||||
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
|
||||
or in the current directory). If there isn't, copy
|
||||
`lldap_config.docker_template.toml` there, and fill in the various values
|
||||
(passwords, secrets, ...).
|
||||
- Check if there is a `users.db` file (either in `/data` for docker or where
|
||||
you specified the DB URL, which defaults to the current directory). If
|
||||
there isn't, check that the user running the command (user with ID 10001
|
||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
[package]
|
||||
name = "lldap_app"
|
||||
version = "0.4.1"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
description = "Frontend for LLDAP"
|
||||
edition = "2021"
|
||||
homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap_app"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.5.1-alpha"
|
||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
base64 = "0.13"
|
||||
gloo-console = "0.2.3"
|
||||
gloo-file = "0.2.3"
|
||||
gloo-net = "*"
|
||||
graphql_client = "0.10"
|
||||
http = "0.2"
|
||||
jwt = "0.13"
|
||||
rand = "0.8"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
url-escape = "0.1.1"
|
||||
validator = "=0.14"
|
||||
validator_derive = "*"
|
||||
wasm-bindgen = "0.2"
|
||||
yew = "0.18"
|
||||
yewtil = "*"
|
||||
yew-router = "0.15"
|
||||
wasm-bindgen-futures = "*"
|
||||
yew = "0.19.3"
|
||||
yew-router = "0.16"
|
||||
|
||||
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
||||
indexmap = "=1.6.2"
|
||||
@@ -28,12 +37,16 @@ version = "0.3"
|
||||
features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"Event",
|
||||
"FileReader",
|
||||
"FormData",
|
||||
"HtmlDocument",
|
||||
"HtmlFormElement",
|
||||
"HtmlInputElement",
|
||||
"HtmlOptionElement",
|
||||
"HtmlOptionsCollection",
|
||||
"HtmlSelectElement",
|
||||
"SubmitEvent",
|
||||
"console",
|
||||
]
|
||||
|
||||
@@ -54,11 +67,11 @@ version = "0.24"
|
||||
|
||||
[dependencies.yew_form]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[dependencies.yew_form_derive]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[lib]
|
||||
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`'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wasm-pack build --target web
|
||||
|
||||
ROLLUP_BIN=$(which rollup 2>/dev/null)
|
||||
if [ -f ../node_modules/rollup/dist/bin/rollup ]
|
||||
if ! which gzip > /dev/null 2>&1
|
||||
then
|
||||
ROLLUP_BIN=../node_modules/rollup/dist/bin/rollup
|
||||
elif [ -f node_modules/rollup/dist/bin/rollup ]
|
||||
then
|
||||
ROLLUP_BIN=node_modules/rollup/dist/bin/rollup
|
||||
fi
|
||||
|
||||
if [ -z "$ROLLUP_BIN" ]
|
||||
then
|
||||
>&2 echo '`rollup` not found. Try running `npm install rollup`'
|
||||
>&2 echo '`gzip` not found.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap
|
||||
wasm-pack build --target web --release
|
||||
|
||||
gzip -9 -k -f pkg/lldap_app_bg.wasm
|
||||
|
||||
@@ -4,17 +4,22 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<base href="/">
|
||||
<script src="static/main.js" type="module" defer></script>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
||||
rel="preload stylesheet"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||
crossorigin="anonymous"
|
||||
as="style" />
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
|
||||
crossorigin="anonymous"></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
|
||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
|
||||
crossorigin="anonymous"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
||||
@@ -29,11 +34,33 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
href="static/style.css" />
|
||||
<script>
|
||||
function inDarkMode(){
|
||||
return darkmode.inDarkMode;
|
||||
}
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
|
||||
LLDAP requires JavaScript, please switch to a compatible browser or
|
||||
enable it.
|
||||
</noscript>
|
||||
|
||||
<script>
|
||||
/* Detect if the user has WASM support. */
|
||||
if (typeof WebAssembly === 'undefined') {
|
||||
const pWASMMsg = document.createElement("p")
|
||||
pWASMMsg.innerHTML = `
|
||||
LLDAP requires WASM and JIT for JavaScript, please switch to a
|
||||
compatible browser or enable it.
|
||||
`
|
||||
document.body.appendChild(pWASMMsg)
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<script src="/static/main.js" type="module" defer></script>
|
||||
<link
|
||||
href="/static/bootstrap.min.css"
|
||||
href="/static/bootstrap-nightshade.min.css"
|
||||
rel="preload stylesheet"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
|
||||
as="style" />
|
||||
<script
|
||||
src="/static/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||
<script
|
||||
src="/static/darkmode.min.js"
|
||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/bootstrap-icons.css"
|
||||
@@ -28,10 +31,32 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<script>
|
||||
function inDarkMode(){
|
||||
return darkmode.inDarkMode;
|
||||
}
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
|
||||
LLDAP requires JavaScript, please switch to a compatible browser or
|
||||
enable it.
|
||||
</noscript>
|
||||
|
||||
<script>
|
||||
/* Detect if the user has WASM support. */
|
||||
if (typeof WebAssembly === 'undefined') {
|
||||
const pWASMMsg = document.createElement("p")
|
||||
pWASMMsg.innerHTML = `
|
||||
LLDAP requires WASM and JIT for JavaScript, please switch to a
|
||||
compatible browser or enable it.
|
||||
`
|
||||
document.body.appendChild(pWASMMsg)
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import init, { run_app } from './pkg/lldap_app.js';
|
||||
async function main() {
|
||||
await init('/pkg/lldap_app_bg.wasm');
|
||||
run_app();
|
||||
}
|
||||
main()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,17 @@ query GetUserDetails($id: String!) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
attributes {
|
||||
name
|
||||
value
|
||||
schema {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,23 +52,25 @@ pub struct Props {
|
||||
}
|
||||
|
||||
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserListResponse(response) => {
|
||||
self.user_list = Some(response?.users);
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddMember => return self.submit_add_member(),
|
||||
Msg::SubmitAddMember => return self.submit_add_member(ctx),
|
||||
Msg::AddMemberResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
let user = self
|
||||
.selected_user
|
||||
.as_ref()
|
||||
.expect("Could not get selected user")
|
||||
.clone();
|
||||
// Remove the user from the dropdown.
|
||||
self.common.on_user_added_to_group.emit(user);
|
||||
ctx.props().on_user_added_to_group.emit(user);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_user.is_some();
|
||||
@@ -88,23 +90,25 @@ impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
|
||||
}
|
||||
|
||||
impl AddGroupMemberComponent {
|
||||
fn get_user_list(&mut self) {
|
||||
fn get_user_list(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<ListUserNames, _>(
|
||||
ctx,
|
||||
list_user_names::Variables { filters: None },
|
||||
Msg::UserListResponse,
|
||||
"Error trying to fetch user list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_member(&mut self) -> Result<bool> {
|
||||
fn submit_add_member(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
let user_id = match self.selected_user.clone() {
|
||||
None => return Ok(false),
|
||||
Some(user) => user.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
ctx,
|
||||
add_user_to_group::Variables {
|
||||
user: user_id,
|
||||
group: self.common.group_id,
|
||||
group: ctx.props().group_id,
|
||||
},
|
||||
Msg::AddMemberResponse,
|
||||
"Error trying to initiate adding the user to a group",
|
||||
@@ -112,8 +116,8 @@ impl AddGroupMemberComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
|
||||
fn get_selectable_user_list(&self, ctx: &Context<Self>, user_list: &[User]) -> Vec<User> {
|
||||
let user_groups = ctx.props().users.iter().collect::<HashSet<_>>();
|
||||
user_list
|
||||
.iter()
|
||||
.filter(|u| !user_groups.contains(u))
|
||||
@@ -126,41 +130,39 @@ impl Component for AddGroupMemberComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user_list: None,
|
||||
selected_user: None,
|
||||
};
|
||||
res.get_user_list();
|
||||
res.get_user_list(ctx);
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
if let Some(user_list) = &self.user_list {
|
||||
let to_add_user_list = self.get_selectable_user_list(user_list);
|
||||
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||
#[allow(unused_braces)]
|
||||
let make_select_option = |user: User| {
|
||||
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! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||
{
|
||||
to_add_user_list
|
||||
.into_iter()
|
||||
@@ -169,12 +171,13 @@ impl Component for AddGroupMemberComponent {
|
||||
}
|
||||
</Select>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_user.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
|
||||
{"Add"}
|
||||
class="btn btn-secondary"
|
||||
disabled={self.selected_user.is_none() || self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitAddMember)}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Add to group"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,16 +64,18 @@ pub struct Props {
|
||||
}
|
||||
|
||||
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupListResponse(response) => {
|
||||
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
|
||||
self.common.cancel_task();
|
||||
}
|
||||
Msg::SubmitAddGroup => return self.submit_add_group(),
|
||||
Msg::SubmitAddGroup => return self.submit_add_group(ctx),
|
||||
Msg::AddGroupResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
// Adding the user to the group succeeded, we're not in the process of adding a
|
||||
// group anymore.
|
||||
let group = self
|
||||
@@ -82,7 +84,7 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
.expect("Could not get selected group")
|
||||
.clone();
|
||||
// Remove the group from the dropdown.
|
||||
self.common.on_user_added_to_group.emit(group);
|
||||
ctx.props().on_user_added_to_group.emit(group);
|
||||
}
|
||||
Msg::SelectionChanged(option_props) => {
|
||||
let was_some = self.selected_group.is_some();
|
||||
@@ -102,22 +104,24 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
|
||||
}
|
||||
|
||||
impl AddUserToGroupComponent {
|
||||
fn get_group_list(&mut self) {
|
||||
fn get_group_list(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetGroupList, _>(
|
||||
ctx,
|
||||
get_group_list::Variables,
|
||||
Msg::GroupListResponse,
|
||||
"Error trying to fetch group list",
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_add_group(&mut self) -> Result<bool> {
|
||||
fn submit_add_group(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
let group_id = match &self.selected_group {
|
||||
None => return Ok(false),
|
||||
Some(group) => group.id,
|
||||
};
|
||||
self.common.call_graphql::<AddUserToGroup, _>(
|
||||
ctx,
|
||||
add_user_to_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
user: ctx.props().username.clone(),
|
||||
group: group_id,
|
||||
},
|
||||
Msg::AddGroupResponse,
|
||||
@@ -126,8 +130,8 @@ impl AddUserToGroupComponent {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
|
||||
fn get_selectable_group_list(&self, props: &Props, group_list: &[Group]) -> Vec<Group> {
|
||||
let user_groups = props.groups.iter().collect::<HashSet<_>>();
|
||||
group_list
|
||||
.iter()
|
||||
.filter(|g| !user_groups.contains(g))
|
||||
@@ -139,41 +143,39 @@ impl AddUserToGroupComponent {
|
||||
impl Component for AddUserToGroupComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut res = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group_list: None,
|
||||
selected_group: None,
|
||||
};
|
||||
res.get_group_list();
|
||||
res.get_group_list(ctx);
|
||||
res
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
if let Some(group_list) = &self.group_list {
|
||||
let to_add_group_list = self.get_selectable_group_list(group_list);
|
||||
let to_add_group_list = self.get_selectable_group_list(ctx.props(), group_list);
|
||||
#[allow(unused_braces)]
|
||||
let make_select_option = |group: Group| {
|
||||
html_nested! {
|
||||
<SelectOption value=group.id.to_string() text=group.display_name key=group.id />
|
||||
<SelectOption value={group.id.to_string()} text={group.display_name} key={group.id} />
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
|
||||
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
|
||||
{
|
||||
to_add_group_list
|
||||
.into_iter()
|
||||
@@ -182,12 +184,13 @@ impl Component for AddUserToGroupComponent {
|
||||
}
|
||||
</Select>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<div class="col-sm-3">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled=self.selected_group.is_none() || self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
|
||||
{"Add"}
|
||||
class="btn btn-secondary"
|
||||
disabled={self.selected_group.is_none() || self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitAddGroup)}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Add to group"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,169 +1,201 @@
|
||||
use crate::{
|
||||
components::{
|
||||
banner::Banner,
|
||||
change_password::ChangePasswordForm,
|
||||
create_group::CreateGroupForm,
|
||||
create_group_attribute::CreateGroupAttributeForm,
|
||||
create_user::CreateUserForm,
|
||||
create_user_attribute::CreateUserAttributeForm,
|
||||
group_details::GroupDetails,
|
||||
group_schema_table::ListGroupSchema,
|
||||
group_table::GroupTable,
|
||||
login::LoginForm,
|
||||
logout::LogoutButton,
|
||||
reset_password_step1::ResetPasswordStep1Form,
|
||||
reset_password_step2::ResetPasswordStep2Form,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
router::{AppRoute, Link, Redirect},
|
||||
user_details::UserDetails,
|
||||
user_schema_table::ListUserSchema,
|
||||
user_table::UserTable,
|
||||
},
|
||||
infra::cookies::get_cookie,
|
||||
};
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
router::Router,
|
||||
service::RouteService,
|
||||
infra::{api::HostService, cookies::get_cookie},
|
||||
};
|
||||
|
||||
use gloo_console::error;
|
||||
use yew::{
|
||||
function_component,
|
||||
html::Scope,
|
||||
prelude::{html, Component, Html},
|
||||
Context,
|
||||
};
|
||||
use yew_router::{
|
||||
prelude::{History, Location},
|
||||
scope_ext::RouterScopeExt,
|
||||
BrowserRouter, Switch,
|
||||
};
|
||||
|
||||
#[function_component(AppContainer)]
|
||||
pub fn app_container() -> Html {
|
||||
html! {
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
}
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
link: ComponentLink<Self>,
|
||||
user_info: Option<(String, bool)>,
|
||||
redirect_to: Option<AppRoute>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
password_reset_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Login((String, bool)),
|
||||
Logout,
|
||||
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||
}
|
||||
|
||||
impl Component for App {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
let mut app = Self {
|
||||
link,
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let app = Self {
|
||||
user_info: get_cookie("user_id")
|
||||
.unwrap_or_else(|e| {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
None
|
||||
})
|
||||
.and_then(|u| {
|
||||
get_cookie("is_admin")
|
||||
.map(|so| so.map(|s| (u, s == "true")))
|
||||
.unwrap_or_else(|e| {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
None
|
||||
})
|
||||
}),
|
||||
redirect_to: Self::get_redirect_route(),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
redirect_to: Self::get_redirect_route(ctx),
|
||||
password_reset_enabled: None,
|
||||
};
|
||||
app.apply_initial_redirections();
|
||||
ctx.link().send_future(async move {
|
||||
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
|
||||
});
|
||||
app.apply_initial_redirections(ctx);
|
||||
app
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
let history = ctx.link().history().unwrap();
|
||||
match msg {
|
||||
Msg::Login((user_name, is_admin)) => {
|
||||
self.user_info = Some((user_name.clone(), is_admin));
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
self.redirect_to.take().unwrap_or_else(|| {
|
||||
if is_admin {
|
||||
AppRoute::ListUsers
|
||||
} else {
|
||||
AppRoute::UserDetails(user_name.clone())
|
||||
}
|
||||
}),
|
||||
)));
|
||||
history.push(self.redirect_to.take().unwrap_or_else(|| {
|
||||
if is_admin {
|
||||
AppRoute::ListUsers
|
||||
} else {
|
||||
AppRoute::UserDetails {
|
||||
user_id: user_name.clone(),
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
Msg::Logout => {
|
||||
self.user_info = None;
|
||||
self.redirect_to = None;
|
||||
history.push(AppRoute::Login);
|
||||
}
|
||||
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
|
||||
self.password_reset_enabled = Some(enabled);
|
||||
}
|
||||
Msg::PasswordResetProbeFinished(Err(err)) => {
|
||||
self.password_reset_enabled = Some(false);
|
||||
error!(&format!(
|
||||
"Could not probe for password reset support: {err:#}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.user_info.is_none() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let link = self.link.clone();
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link().clone();
|
||||
let is_admin = self.is_admin();
|
||||
let username = self.user_info.clone().map(|(username, _)| username);
|
||||
let password_reset_enabled = self.password_reset_enabled;
|
||||
html! {
|
||||
<div class="container shadow-sm py-3">
|
||||
{self.view_banner()}
|
||||
<div>
|
||||
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||
<div class="container py-3 bg-kug">
|
||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||
<div class="shadow-sm py-3" style="max-width: 1000px">
|
||||
<Router<AppRoute>
|
||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
||||
<main class="py-3" style="max-width: 1000px">
|
||||
<Switch<AppRoute>
|
||||
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{self.view_footer()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn get_redirect_route() -> Option<AppRoute> {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.is_empty()
|
||||
|| current_route == "/"
|
||||
|| current_route.contains("login")
|
||||
|| current_route.contains("reset-password")
|
||||
{
|
||||
None
|
||||
} else {
|
||||
use yew_router::Switch;
|
||||
AppRoute::from_route_part::<()>(current_route, None).0
|
||||
}
|
||||
// Get the page to land on after logging in, defaulting to the index.
|
||||
fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
|
||||
let route = ctx.link().history().unwrap().location().route::<AppRoute>();
|
||||
route.filter(|route| {
|
||||
!matches!(
|
||||
route,
|
||||
AppRoute::Index
|
||||
| AppRoute::Login
|
||||
| AppRoute::StartResetPassword
|
||||
| AppRoute::FinishResetPassword { token: _ }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_initial_redirections(&mut self) {
|
||||
let route_service = RouteService::<()>::new();
|
||||
let current_route = route_service.get_path();
|
||||
if current_route.contains("reset-password") {
|
||||
return;
|
||||
}
|
||||
match &self.user_info {
|
||||
None => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
fn apply_initial_redirections(&self, ctx: &Context<Self>) {
|
||||
let history = ctx.link().history().unwrap();
|
||||
let route = history.location().route::<AppRoute>();
|
||||
let redirection = match (route, &self.user_info, &self.redirect_to) {
|
||||
(
|
||||
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
|
||||
_,
|
||||
_,
|
||||
) => {
|
||||
if self.password_reset_enabled == Some(false) {
|
||||
Some(AppRoute::Login)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Some((user_name, is_admin)) => match &self.redirect_to {
|
||||
Some(url) => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
|
||||
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
|
||||
// User is logged in, a URL was given, don't redirect.
|
||||
(_, Some(_), Some(_)) => None,
|
||||
(_, Some((user_name, is_admin)), None) => {
|
||||
if *is_admin {
|
||||
Some(AppRoute::ListUsers)
|
||||
} else {
|
||||
Some(AppRoute::UserDetails {
|
||||
user_id: user_name.clone(),
|
||||
})
|
||||
}
|
||||
None => {
|
||||
if *is_admin {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
|
||||
} else {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(
|
||||
AppRoute::UserDetails(user_name.clone()),
|
||||
)));
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
if let Some(redirect_to) = redirection {
|
||||
history.push(redirect_to);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
|
||||
fn dispatch_route(
|
||||
switch: &AppRoute,
|
||||
link: &Scope<Self>,
|
||||
is_admin: bool,
|
||||
password_reset_enabled: Option<bool>,
|
||||
) -> Html {
|
||||
match switch {
|
||||
AppRoute::Login => html! {
|
||||
<LoginForm on_logged_in=link.callback(Msg::Login)/>
|
||||
<LoginForm on_logged_in={link.callback(Msg::Login)} password_reset_enabled={password_reset_enabled.unwrap_or(false)}/>
|
||||
},
|
||||
AppRoute::CreateUser => html! {
|
||||
<CreateUserForm/>
|
||||
@@ -171,115 +203,71 @@ impl App {
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<UserTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Create a user"}
|
||||
</Link>
|
||||
</div>
|
||||
},
|
||||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::CreateUserAttribute => html! {
|
||||
<CreateUserAttributeForm/>
|
||||
},
|
||||
AppRoute::CreateGroupAttribute => html! {
|
||||
<CreateGroupAttributeForm/>
|
||||
},
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Create a group"}
|
||||
</Link>
|
||||
</div>
|
||||
},
|
||||
AppRoute::GroupDetails(group_id) => html! {
|
||||
<GroupDetails group_id=group_id />
|
||||
AppRoute::ListUserSchema => html! {
|
||||
<ListUserSchema />
|
||||
},
|
||||
AppRoute::UserDetails(username) => html! {
|
||||
<UserDetails username=username is_admin=is_admin />
|
||||
AppRoute::ListGroupSchema => html! {
|
||||
<ListGroupSchema />
|
||||
},
|
||||
AppRoute::ChangePassword(username) => html! {
|
||||
<ChangePasswordForm username=username is_admin=is_admin />
|
||||
AppRoute::GroupDetails { group_id } => html! {
|
||||
<GroupDetails group_id={*group_id} />
|
||||
},
|
||||
AppRoute::StartResetPassword => html! {
|
||||
<ResetPasswordStep1Form />
|
||||
AppRoute::UserDetails { user_id } => html! {
|
||||
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||
},
|
||||
AppRoute::FinishResetPassword(token) => html! {
|
||||
<ResetPasswordStep2Form token=token />
|
||||
AppRoute::ChangePassword { user_id } => html! {
|
||||
<ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
|
||||
},
|
||||
}
|
||||
}
|
||||
AppRoute::StartResetPassword => match password_reset_enabled {
|
||||
Some(true) => html! { <ResetPasswordStep1Form /> },
|
||||
Some(false) => {
|
||||
html! { <Redirect to={AppRoute::Login}/> }
|
||||
}
|
||||
|
||||
fn view_banner(&self) -> Html {
|
||||
html! {
|
||||
<header class="p-3 mb-4 border-bottom shadow-sm">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 me-md-5 text-dark text-decoration-none">
|
||||
<h1>{"LLDAP"}</h1>
|
||||
</a>
|
||||
|
||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||
{if self.is_admin() { html! {
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 link-dark h4"
|
||||
route=AppRoute::ListUsers>
|
||||
{"Users"}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
classes="nav-link px-2 link-dark h4"
|
||||
route=AppRoute::ListGroups>
|
||||
{"Groups"}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
} } else { html!{} } }
|
||||
</ul>
|
||||
|
||||
<div class="dropdown text-end">
|
||||
<a href="#"
|
||||
class="d-block link-dark text-decoration-none dropdown-toggle"
|
||||
id="dropdownUser"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="currentColor"
|
||||
class="bi bi-person-circle"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{if let Some((user_id, _)) = &self.user_info { html! {
|
||||
<ul
|
||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||
aria-labelledby="dropdownUser1"
|
||||
style="">
|
||||
<li>
|
||||
<Link
|
||||
classes="dropdown-item"
|
||||
route=AppRoute::UserDetails(user_id.clone())>
|
||||
{"Profile"}
|
||||
</Link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<LogoutButton on_logged_out=self.link.callback(|_| Msg::Logout) />
|
||||
</li>
|
||||
</ul>
|
||||
} } else { html!{} } }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
None => html! {},
|
||||
},
|
||||
AppRoute::FinishResetPassword { token } => match password_reset_enabled {
|
||||
Some(true) => html! { <ResetPasswordStep2Form token={token.clone()} /> },
|
||||
Some(false) => {
|
||||
html! { <Redirect to={AppRoute::Login}/> }
|
||||
}
|
||||
None => html! {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn view_footer(&self) -> Html {
|
||||
html! {
|
||||
<footer class="text-center text-muted fixed-bottom bg-light">
|
||||
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||
<div>
|
||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/nitnelave/lldap" class="me-4 text-reset">
|
||||
<a href="https://github.com/lldap/lldap" class="me-4 text-reset">
|
||||
<i class="bi-github"></i>
|
||||
</a>
|
||||
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
|
||||
@@ -290,7 +278,7 @@ impl App {
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span>{"License "}<a href="https://github.com/nitnelave/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
||||
<span>{"License "}<a href="https://github.com/lldap/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
|
||||
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::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
#[derive(PartialEq, Eq, Default)]
|
||||
enum OpaqueData {
|
||||
#[default]
|
||||
None,
|
||||
Login(opaque::client::login::ClientLogin),
|
||||
Registration(opaque::client::registration::ClientRegistration),
|
||||
}
|
||||
|
||||
impl Default for OpaqueData {
|
||||
fn default() -> Self {
|
||||
OpaqueData::None
|
||||
}
|
||||
}
|
||||
|
||||
impl OpaqueData {
|
||||
fn take(&mut self) -> Self {
|
||||
std::mem::take(self)
|
||||
@@ -61,7 +57,6 @@ pub struct ChangePasswordForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
opaque_data: OpaqueData,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
@@ -80,15 +75,20 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
use anyhow::Context;
|
||||
match msg {
|
||||
Msg::FormUpdate => Ok(true),
|
||||
Msg::Submit => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
if self.common.is_admin {
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
if ctx.props().is_admin {
|
||||
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||
} else {
|
||||
let old_password = self.form.model().old_password;
|
||||
if old_password.is_empty() {
|
||||
@@ -100,14 +100,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
.context("Could not initialize login")?;
|
||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username: self.common.username.clone(),
|
||||
username: ctx.props().username.clone().into(),
|
||||
login_start_request: login_start_request.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::login_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::login_start(req),
|
||||
Msg::AuthenticationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -119,34 +119,33 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
|e| {
|
||||
// Common error, we want to print a full error to the console but only a
|
||||
// simple one to the user.
|
||||
ConsoleService::error(&format!(
|
||||
"Invalid username or password: {}",
|
||||
e
|
||||
));
|
||||
error!(&format!("Invalid username or password: {}", e));
|
||||
anyhow!("Invalid username or password")
|
||||
},
|
||||
)?;
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
};
|
||||
self.handle_msg(Msg::SubmitNewPassword)
|
||||
self.handle_msg(ctx, Msg::SubmitNewPassword)
|
||||
}
|
||||
Msg::SubmitNewPassword => {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let new_password = self.form.model().password;
|
||||
let registration_start_request =
|
||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let registration_start_request = opaque::client::registration::start_registration(
|
||||
new_password.as_bytes(),
|
||||
&mut rng,
|
||||
)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.common.username.clone(),
|
||||
username: ctx.props().username.clone().into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_start(req),
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
@@ -166,22 +165,20 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
);
|
||||
}
|
||||
_ => panic!("Unexpected data in opaque_data field"),
|
||||
}?;
|
||||
};
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(
|
||||
AppRoute::UserDetails(self.common.username.clone()),
|
||||
)));
|
||||
ctx.link().history().unwrap().push(AppRoute::UserDetails {
|
||||
user_id: ctx.props().username.clone(),
|
||||
});
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
@@ -198,116 +195,76 @@ impl Component for ChangePasswordForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
ChangePasswordForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: OpaqueData::None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
let is_admin = self.common.is_admin;
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let is_admin = ctx.props().is_admin;
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<form
|
||||
class="form">
|
||||
{if !is_admin { html! {
|
||||
<div class="form-group row">
|
||||
<label for="old_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Current password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="old_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="current-password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("old_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}} else { html! {} }}
|
||||
<div class="form-group row">
|
||||
<label for="new_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"New password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Confirm password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<button
|
||||
class="btn btn-primary col-sm-1 col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
<div class="mb-2 mt-2">
|
||||
<h5 class="fw-bold">
|
||||
{"Change password"}
|
||||
</h5>
|
||||
</div>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-danger mt-3 mb-3">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div>
|
||||
<NavButton
|
||||
classes="btn btn-primary"
|
||||
route=AppRoute::UserDetails(self.common.username.clone())>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</div>
|
||||
<form class="form">
|
||||
{if !is_admin { html! {
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Current password"
|
||||
field_name="old_password"
|
||||
input_type="password"
|
||||
autocomplete="current-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
}} else { html! {} }}
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="New password"
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Field<FormModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Confirm password"
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}
|
||||
text="Save changes" >
|
||||
<Link
|
||||
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
||||
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
||||
<i class="bi-arrow-return-left me-2"></i>
|
||||
{"Back"}
|
||||
</Link>
|
||||
</Submit>
|
||||
</form>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -24,7 +24,6 @@ pub struct CreateGroup;
|
||||
|
||||
pub struct CreateGroupForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateGroupModel>,
|
||||
}
|
||||
|
||||
@@ -41,7 +40,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
@@ -53,6 +56,7 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
name: model.groupname,
|
||||
};
|
||||
self.common.call_graphql::<CreateGroup, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateGroupResponse,
|
||||
"Error trying to create group",
|
||||
@@ -60,12 +64,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateGroupResponse(response) => {
|
||||
ConsoleService::log(&format!(
|
||||
log!(&format!(
|
||||
"Created group '{}'",
|
||||
&response?.create_group.display_name
|
||||
));
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -80,58 +83,34 @@ impl Component for CreateGroupForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<CreateGroupModel>;
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form shadow-sm py-3" style="max-width: 636px">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="groupname"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group name*:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="groupname"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="groupname"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("groupname")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<Field<CreateGroupModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Group name"
|
||||
field_name="groupname"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<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! {
|
||||
|
||||
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::{
|
||||
components::router::AppRoute,
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{opaque, registration};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew::services::ConsoleService;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
@@ -28,7 +28,6 @@ pub struct CreateUser;
|
||||
|
||||
pub struct CreateUserForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
form: yew_form::Form<CreateUserModel>,
|
||||
}
|
||||
|
||||
@@ -38,7 +37,6 @@ pub struct CreateUserModel {
|
||||
username: String,
|
||||
#[validate(email(message = "A valid email is required"))]
|
||||
email: String,
|
||||
#[validate(length(min = 1, message = "Display name is required"))]
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
@@ -74,7 +72,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
@@ -91,9 +93,11 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
firstName: to_option(model.first_name),
|
||||
lastName: to_option(model.last_name),
|
||||
avatar: None,
|
||||
attributes: None,
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateUserResponse,
|
||||
"Error trying to create user",
|
||||
@@ -103,7 +107,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
Msg::CreateUserResponse(r) => {
|
||||
match r {
|
||||
Err(e) => return Err(e),
|
||||
Ok(r) => ConsoleService::log(&format!(
|
||||
Ok(r) => log!(&format!(
|
||||
"Created user '{}' at '{}'",
|
||||
&r.create_user.id, &r.create_user.creation_date
|
||||
)),
|
||||
@@ -117,18 +121,20 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
let opaque::client::registration::ClientRegistrationStartResult {
|
||||
state,
|
||||
message,
|
||||
} = opaque::client::registration::start_registration(&password, &mut rng)?;
|
||||
} = opaque::client::registration::start_registration(
|
||||
password.as_bytes(),
|
||||
&mut rng,
|
||||
)?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: user_id,
|
||||
username: user_id.into(),
|
||||
registration_start_request: message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(HostService::register_start, req, move |r| {
|
||||
.call_backend(ctx, HostService::register_start(req), move |r| {
|
||||
Msg::RegistrationStartResponse((state, r))
|
||||
})
|
||||
.context("Error trying to create user")?;
|
||||
});
|
||||
} else {
|
||||
self.update(Msg::SuccessfulCreation);
|
||||
self.update(ctx, Msg::SuccessfulCreation);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -144,22 +150,19 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
server_data: response.server_data,
|
||||
registration_upload: registration_upload.message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
Msg::RegistrationFinishResponse,
|
||||
)
|
||||
.context("Error trying to register user")?;
|
||||
self.common.call_backend(
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
response?;
|
||||
self.handle_msg(Msg::SuccessfulCreation)
|
||||
self.handle_msg(ctx, Msg::SuccessfulCreation)
|
||||
}
|
||||
Msg::SuccessfulCreation => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListUsers)));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListUsers);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -174,177 +177,73 @@ impl Component for CreateUserForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<CreateUserModel>;
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form shadow-sm py-3" style="max-width: 636px">
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a user"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="username"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"User name*:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="username"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("username")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email*:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
input_type="email"
|
||||
field_name="email"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="email"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="display-name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Display name*:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
autocomplete="name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="display_name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="first-name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"First name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
autocomplete="given-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="first_name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="last-name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Last name:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
autocomplete="family-name"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
field_name="last_name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="password"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Password:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
input_type="password"
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Confirm password:"}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
form=&self.form
|
||||
input_type="password"
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary col-auto col-form-label mt-4"
|
||||
disabled=self.common.is_task_running()
|
||||
type="submit"
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="User name"
|
||||
field_name="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Email"
|
||||
field_name="email"
|
||||
input_type="email"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Display name"
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="First name"
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Last name"
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Password"
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Confirm password"
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
|
||||
175
app/src/components/create_user_attribute.rs
Normal file
175
app/src/components/create_user_attribute.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
convert_attribute_type,
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::{validate_attribute_type, AttributeType},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/create_user_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct CreateUserAttribute;
|
||||
|
||||
convert_attribute_type!(create_user_attribute::AttributeType);
|
||||
|
||||
pub struct CreateUserAttributeForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateUserAttributeModel>,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||
pub struct CreateUserAttributeModel {
|
||||
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||
attribute_name: String,
|
||||
#[validate(custom = "validate_attribute_type")]
|
||||
attribute_type: String,
|
||||
is_editable: bool,
|
||||
is_list: bool,
|
||||
is_visible: bool,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
SubmitForm,
|
||||
CreateUserAttributeResponse(Result<create_user_attribute::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
let model = self.form.model();
|
||||
if model.is_editable && !model.is_visible {
|
||||
bail!("Editable attributes must also be visible");
|
||||
}
|
||||
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||
let req = create_user_attribute::Variables {
|
||||
name: model.attribute_name,
|
||||
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
|
||||
is_editable: model.is_editable,
|
||||
is_list: model.is_list,
|
||||
is_visible: model.is_visible,
|
||||
};
|
||||
self.common.call_graphql::<CreateUserAttribute, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::CreateUserAttributeResponse,
|
||||
"Error trying to create user attribute",
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::CreateUserAttributeResponse(response) => {
|
||||
response?;
|
||||
let model = self.form.model();
|
||||
log!(&format!(
|
||||
"Created user attribute '{}'",
|
||||
model.attribute_name
|
||||
));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListUserSchema);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CreateUserAttributeForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
let model = CreateUserAttributeModel {
|
||||
attribute_type: AttributeType::String.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserAttributeModel>::new(model),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<h5 class="fw-bold">{"Create a user attribute"}</h5>
|
||||
<Field<CreateUserAttributeModel>
|
||||
label="Name"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_name"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Select<CreateUserAttributeModel>
|
||||
label="Type"
|
||||
required={true}
|
||||
form={&self.form}
|
||||
field_name="attribute_type"
|
||||
oninput={link.callback(|_| Msg::Update)}>
|
||||
<option selected=true value="String">{"String"}</option>
|
||||
<option value="Integer">{"Integer"}</option>
|
||||
<option value="Jpeg">{"Jpeg"}</option>
|
||||
<option value="DateTime">{"DateTime"}</option>
|
||||
</Select<CreateUserAttributeModel>>
|
||||
<CheckBox<CreateUserAttributeModel>
|
||||
label="Multiple values"
|
||||
form={&self.form}
|
||||
field_name="is_list"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<CheckBox<CreateUserAttributeModel>
|
||||
label="Visible to users"
|
||||
form={&self.form}
|
||||
field_name="is_visible"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<CheckBox<CreateUserAttributeModel>
|
||||
label="Editable by users"
|
||||
form={&self.form}
|
||||
field_name="is_editable"
|
||||
ontoggle={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,16 +39,21 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteGroup => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroup => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteGroupQuery, _>(
|
||||
ctx,
|
||||
delete_group_query::Variables {
|
||||
group_id: self.common.group.id,
|
||||
group_id: ctx.props().group.id,
|
||||
},
|
||||
Msg::DeleteGroupResponse,
|
||||
"Error trying to delete group",
|
||||
@@ -58,12 +63,8 @@ impl CommonComponent<DeleteGroup> for DeleteGroup {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
.on_group_deleted
|
||||
.emit(self.common.group.id);
|
||||
ctx.props().on_group_deleted.emit(ctx.props().group.id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -78,15 +79,15 @@ impl Component for DeleteGroup {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteGroupProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, first_render: bool) {
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
@@ -96,43 +97,42 @@ impl Component for DeleteGroup {
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteGroup)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete group" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteGroup {
|
||||
fn show_modal(&self) -> Html {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
|
||||
id={"deleteGroupModal".to_string() + &ctx.props().group.id.to_string()}
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteGroupModalLabel"
|
||||
aria-hidden="true"
|
||||
ref=self.node_ref.clone()>
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -141,25 +141,29 @@ impl DeleteGroup {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete group "}
|
||||
<b>{&self.common.group.display_name}</b>{"?"}
|
||||
<b>{&ctx.props().group.display_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteGroup)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
172
app/src/components/delete_group_attribute.rs
Normal file
172
app/src/components/delete_group_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use crate::infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/delete_group_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct DeleteGroupAttributeQuery;
|
||||
|
||||
pub struct DeleteGroupAttribute {
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
pub struct DeleteGroupAttributeProps {
|
||||
pub attribute_name: String,
|
||||
pub on_attribute_deleted: Callback<String>,
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ClickedDeleteGroupAttribute,
|
||||
ConfirmDeleteGroupAttribute,
|
||||
DismissModal,
|
||||
DeleteGroupAttributeResponse(Result<delete_group_attribute_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteGroupAttribute> for DeleteGroupAttribute {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteGroupAttribute => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteGroupAttribute => {
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteGroupAttributeQuery, _>(
|
||||
ctx,
|
||||
delete_group_attribute_query::Variables {
|
||||
name: ctx.props().attribute_name.clone(),
|
||||
},
|
||||
Msg::DeleteGroupAttributeResponse,
|
||||
"Error trying to delete group attribute",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteGroupAttributeResponse(response) => {
|
||||
response?;
|
||||
ctx.props()
|
||||
.on_attribute_deleted
|
||||
.emit(ctx.props().attribute_name.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteGroupAttribute {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteGroupAttributeProps;
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
.cast::<web_sys::Element>()
|
||||
.expect("Modal node is not an element"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteGroupAttribute)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||
</button>
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteGroupAttribute {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id={"deleteGroupAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteGroupAttributeModalLabel"
|
||||
aria-hidden="true"
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteGroupAttributeModalLabel">{"Delete group attribute?"}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete group attribute "}
|
||||
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteGroupAttribute)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,16 +36,21 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteUser> for DeleteUser {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteUser => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUser => {
|
||||
self.update(Msg::DismissModal);
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteUserQuery, _>(
|
||||
ctx,
|
||||
delete_user_query::Variables {
|
||||
user: self.common.username.clone(),
|
||||
user: ctx.props().username.clone(),
|
||||
},
|
||||
Msg::DeleteUserResponse,
|
||||
"Error trying to delete user",
|
||||
@@ -55,12 +60,10 @@ impl CommonComponent<DeleteUser> for DeleteUser {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
response?;
|
||||
self.common
|
||||
.props
|
||||
ctx.props()
|
||||
.on_user_deleted
|
||||
.emit(self.common.username.clone());
|
||||
.emit(ctx.props().username.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -75,15 +78,15 @@ impl Component for DeleteUser {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteUserProps;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, first_render: bool) {
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
@@ -93,44 +96,43 @@ impl Component for DeleteUser {
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteUser)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete user" />
|
||||
</button>
|
||||
{self.show_modal()}
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteUser {
|
||||
fn show_modal(&self) -> Html {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id="deleteUserModal".to_string() + &self.common.username
|
||||
id={"deleteUserModal".to_string() + &ctx.props().username}
|
||||
tabindex="-1"
|
||||
//role="dialog"
|
||||
aria-labelledby="deleteUserModalLabel"
|
||||
aria-hidden="true"
|
||||
ref=self.node_ref.clone()>
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog" /*role="document"*/>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -139,25 +141,29 @@ impl DeleteUser {
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal) />
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete user "}
|
||||
<b>{&self.common.username}</b>{"?"}
|
||||
<b>{&ctx.props().username}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick=self.common.callback(|_| Msg::DismissModal)>
|
||||
{"Cancel"}
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
|
||||
class="btn btn-danger">{"Yes, I'm sure"}</button>
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteUser)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
172
app/src/components/delete_user_attribute.rs
Normal file
172
app/src/components/delete_user_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use crate::infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
modal::Modal,
|
||||
};
|
||||
use anyhow::{Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/delete_user_attribute.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct DeleteUserAttributeQuery;
|
||||
|
||||
pub struct DeleteUserAttribute {
|
||||
common: CommonComponentParts<Self>,
|
||||
node_ref: NodeRef,
|
||||
modal: Option<Modal>,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||
pub struct DeleteUserAttributeProps {
|
||||
pub attribute_name: String,
|
||||
pub on_attribute_deleted: Callback<String>,
|
||||
pub on_error: Callback<Error>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ClickedDeleteUserAttribute,
|
||||
ConfirmDeleteUserAttribute,
|
||||
DismissModal,
|
||||
DeleteUserAttributeResponse(Result<delete_user_attribute_query::ResponseData>),
|
||||
}
|
||||
|
||||
impl CommonComponent<DeleteUserAttribute> for DeleteUserAttribute {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ClickedDeleteUserAttribute => {
|
||||
self.modal.as_ref().expect("modal not initialized").show();
|
||||
}
|
||||
Msg::ConfirmDeleteUserAttribute => {
|
||||
self.update(ctx, Msg::DismissModal);
|
||||
self.common.call_graphql::<DeleteUserAttributeQuery, _>(
|
||||
ctx,
|
||||
delete_user_attribute_query::Variables {
|
||||
name: ctx.props().attribute_name.clone(),
|
||||
},
|
||||
Msg::DeleteUserAttributeResponse,
|
||||
"Error trying to delete user attribute",
|
||||
);
|
||||
}
|
||||
Msg::DismissModal => {
|
||||
self.modal.as_ref().expect("modal not initialized").hide();
|
||||
}
|
||||
Msg::DeleteUserAttributeResponse(response) => {
|
||||
response?;
|
||||
ctx.props()
|
||||
.on_attribute_deleted
|
||||
.emit(ctx.props().attribute_name.clone());
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for DeleteUserAttribute {
|
||||
type Message = Msg;
|
||||
type Properties = DeleteUserAttributeProps;
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
node_ref: NodeRef::default(),
|
||||
modal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
self.modal = Some(Modal::new(
|
||||
self.node_ref
|
||||
.cast::<web_sys::Element>()
|
||||
.expect("Modal node is not an element"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::ClickedDeleteUserAttribute)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||
</button>
|
||||
{self.show_modal(ctx)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteUserAttribute {
|
||||
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div
|
||||
class="modal fade"
|
||||
id={"deleteUserAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||
tabindex="-1"
|
||||
aria-labelledby="deleteUserAttributeModalLabel"
|
||||
aria-hidden="true"
|
||||
ref={self.node_ref.clone()}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteUserAttributeModalLabel">{"Delete user attribute?"}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span>
|
||||
{"Are you sure you want to delete user attribute "}
|
||||
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||
<i class="bi-x-circle me-2"></i>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={link.callback(|_| Msg::ConfirmDeleteUserAttribute)}
|
||||
class="btn btn-danger">
|
||||
<i class="bi-check-circle me-2"></i>
|
||||
{"Yes, I'm sure"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,11 @@ pub struct Props {
|
||||
}
|
||||
|
||||
impl GroupDetails {
|
||||
fn get_group_details(&mut self) {
|
||||
fn get_group_details(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetGroupDetails, _>(
|
||||
ctx,
|
||||
get_group_details::Variables {
|
||||
id: self.common.group_id,
|
||||
id: ctx.props().group_id,
|
||||
},
|
||||
Msg::GroupDetailsResponse,
|
||||
"Error trying to fetch group details",
|
||||
@@ -89,7 +90,7 @@ impl GroupDetails {
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{g.creation_date.date().naive_local()}</span>
|
||||
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
@@ -107,24 +108,25 @@ impl GroupDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_list(&self, g: &Group) -> Html {
|
||||
fn view_user_list(&self, ctx: &Context<Self>, g: &Group) -> Html {
|
||||
let link = ctx.link();
|
||||
let make_user_row = |user: &User| {
|
||||
let user_id = user.id.clone();
|
||||
let display_name = user.display_name.clone();
|
||||
html! {
|
||||
<tr>
|
||||
<td>
|
||||
<Link route=AppRoute::UserDetails(user_id.clone())>
|
||||
<Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
|
||||
{user_id.clone()}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{display_name}</td>
|
||||
<td>
|
||||
<RemoveUserFromGroupComponent
|
||||
username=user_id
|
||||
group_id=g.id
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={user_id}
|
||||
group_id={g.id}
|
||||
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -133,7 +135,7 @@ impl GroupDetails {
|
||||
<>
|
||||
<h5 class="fw-bold">{"Members"}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr key="headerRow">
|
||||
<th>{"User Id"}</th>
|
||||
@@ -145,7 +147,7 @@ impl GroupDetails {
|
||||
{if g.users.is_empty() {
|
||||
html! {
|
||||
<tr key="EmptyRow">
|
||||
<td>{"No members"}</td>
|
||||
<td>{"There are no users in this group."}</td>
|
||||
<td/>
|
||||
</tr>
|
||||
}
|
||||
@@ -159,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
|
||||
.users
|
||||
.iter()
|
||||
@@ -170,16 +173,16 @@ impl GroupDetails {
|
||||
.collect();
|
||||
html! {
|
||||
<AddGroupMemberComponent
|
||||
group_id=g.id
|
||||
users=users
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
group_id={g.id}
|
||||
users={users}
|
||||
on_error={link.callback(Msg::OnError)}
|
||||
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
@@ -215,24 +218,20 @@ impl Component for GroupDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group: None,
|
||||
};
|
||||
table.get_group_details();
|
||||
table.get_group_details(ctx);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.group, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
@@ -240,8 +239,8 @@ impl Component for GroupDetails {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_details(u)}
|
||||
{self.view_user_list(u)}
|
||||
{self.view_add_user_button(u)}
|
||||
{self.view_user_list(ctx, u)}
|
||||
{self.view_add_user_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</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>
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupTable> for GroupTable {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListGroupsResponse(groups) => {
|
||||
self.groups = Some(groups?.groups.into_iter().collect());
|
||||
@@ -58,12 +58,13 @@ impl Component for GroupTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = GroupTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
groups: None,
|
||||
};
|
||||
table.common.call_graphql::<GetGroupList, _>(
|
||||
ctx,
|
||||
get_group_list::Variables {},
|
||||
Msg::ListGroupsResponse,
|
||||
"Error trying to fetch groups",
|
||||
@@ -71,18 +72,14 @@ impl Component for GroupTable {
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_groups()}
|
||||
{self.view_groups(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
@@ -90,11 +87,11 @@ impl Component for GroupTable {
|
||||
}
|
||||
|
||||
impl GroupTable {
|
||||
fn view_groups(&self) -> Html {
|
||||
fn view_groups(&self, ctx: &Context<Self>) -> Html {
|
||||
let make_table = |groups: &Vec<Group>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Group name"}</th>
|
||||
@@ -103,7 +100,7 @@ impl GroupTable {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.iter().map(|u| self.view_group(u)).collect::<Vec<_>>()}
|
||||
{groups.iter().map(|u| self.view_group(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -115,22 +112,23 @@ impl GroupTable {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_group(&self, group: &Group) -> Html {
|
||||
fn view_group(&self, ctx: &Context<Self>, group: &Group) -> Html {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<tr key=group.id>
|
||||
<tr key={group.id}>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||
{&group.display_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{&group.creation_date.date().naive_local()}
|
||||
{&group.creation_date.naive_local().date()}
|
||||
</td>
|
||||
<td>
|
||||
<DeleteGroup
|
||||
group=group.clone()
|
||||
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
group={group.clone()}
|
||||
on_group_deleted={link.callback(Msg::OnGroupDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::{
|
||||
form::submit::Submit,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
@@ -30,6 +34,7 @@ pub struct FormModel {
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct Props {
|
||||
pub on_logged_in: Callback<(String, bool)>,
|
||||
pub password_reset_enabled: bool,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
@@ -46,7 +51,12 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<LoginForm> for LoginForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
use anyhow::Context;
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@@ -59,13 +69,13 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
opaque::client::login::start_login(&password, &mut rng)
|
||||
.context("Could not initialize login")?;
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username,
|
||||
username: username.into(),
|
||||
login_start_request: message,
|
||||
};
|
||||
self.common
|
||||
.call_backend(HostService::login_start, req, move |r| {
|
||||
.call_backend(ctx, HostService::login_start(req), move |r| {
|
||||
Msg::AuthenticationStartResponse((state, r))
|
||||
})?;
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationStartResponse((login_start, res)) => {
|
||||
@@ -76,9 +86,8 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
Err(e) => {
|
||||
// Common error, we want to print a full error to the console but only a
|
||||
// simple one to the user.
|
||||
ConsoleService::error(&format!("Invalid username or password: {}", e));
|
||||
error!(&format!("Invalid username or password: {}", e));
|
||||
self.common.error = Some(anyhow!("Invalid username or password"));
|
||||
self.common.cancel_task();
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(l) => l,
|
||||
@@ -88,24 +97,22 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
credential_finalization: login_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::login_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::login_finish(req),
|
||||
Msg::AuthenticationFinishResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::AuthenticationFinishResponse(user_info) => {
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
ctx.props()
|
||||
.on_logged_in
|
||||
.emit(user_info.context("Could not log in")?);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationRefreshResponse(user_info) => {
|
||||
self.refreshing = false;
|
||||
self.common.cancel_task();
|
||||
if let Ok(user_info) = user_info {
|
||||
self.common.on_logged_in.emit(user_info);
|
||||
ctx.props().on_logged_in.emit(user_info);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -121,32 +128,28 @@ impl Component for LoginForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut app = LoginForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
refreshing: true,
|
||||
};
|
||||
if let Err(e) =
|
||||
app.common
|
||||
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse)
|
||||
{
|
||||
ConsoleService::debug(&format!("Could not refresh auth: {}", e));
|
||||
app.refreshing = false;
|
||||
}
|
||||
app.common.call_backend(
|
||||
ctx,
|
||||
HostService::refresh(),
|
||||
Msg::AuthenticationRefreshResponse,
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
let password_reset_enabled = ctx.props().password_reset_enabled;
|
||||
let link = &ctx.link();
|
||||
if self.refreshing {
|
||||
html! {
|
||||
<div>
|
||||
@@ -155,61 +158,62 @@ impl Component for LoginForm {
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<form class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Login"}
|
||||
</button>
|
||||
<NavButton
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::StartResetPassword>
|
||||
{"Forgot your password?"}
|
||||
</NavButton>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<Submit
|
||||
text="Login"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
{ if password_reset_enabled {
|
||||
html! {
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::StartResetPassword}>
|
||||
{"Forgot your password?"}
|
||||
</Link>
|
||||
}
|
||||
} else {
|
||||
html!{}
|
||||
}}
|
||||
</Submit>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,20 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<LogoutButton> for LogoutButton {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::LogoutRequested => {
|
||||
self.common
|
||||
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
|
||||
.call_backend(ctx, HostService::logout(), Msg::LogoutCompleted);
|
||||
}
|
||||
Msg::LogoutCompleted(res) => {
|
||||
res?;
|
||||
delete_cookie("user_id")?;
|
||||
self.common.on_logged_out.emit(());
|
||||
ctx.props().on_logged_out.emit(());
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
@@ -45,25 +49,22 @@ impl Component for LogoutButton {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
LogoutButton {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick=self.common.callback(|_| Msg::LogoutRequested)>
|
||||
onclick={link.callback(|_| Msg::LogoutRequested)}>
|
||||
{"Logout"}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
pub mod add_group_member;
|
||||
pub mod add_user_to_group;
|
||||
pub mod app;
|
||||
pub mod avatar;
|
||||
pub mod banner;
|
||||
pub mod change_password;
|
||||
pub mod create_group;
|
||||
pub mod create_group_attribute;
|
||||
pub mod create_user;
|
||||
pub mod create_user_attribute;
|
||||
pub mod delete_group;
|
||||
pub mod delete_group_attribute;
|
||||
pub mod delete_user;
|
||||
pub mod delete_user_attribute;
|
||||
pub mod form;
|
||||
pub mod group_details;
|
||||
pub mod group_schema_table;
|
||||
pub mod group_table;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
@@ -17,4 +25,5 @@ pub mod router;
|
||||
pub mod select;
|
||||
pub mod user_details;
|
||||
pub mod user_details_form;
|
||||
pub mod user_schema_table;
|
||||
pub mod user_table;
|
||||
|
||||
@@ -31,15 +31,18 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(),
|
||||
Msg::SubmitRemoveGroup => self.submit_remove_group(ctx),
|
||||
Msg::RemoveGroupResponse(response) => {
|
||||
response?;
|
||||
self.common.cancel_task();
|
||||
self.common
|
||||
ctx.props()
|
||||
.on_user_removed_from_group
|
||||
.emit((self.common.username.clone(), self.common.group_id));
|
||||
.emit((ctx.props().username.clone(), ctx.props().group_id));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -51,11 +54,12 @@ impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupCompon
|
||||
}
|
||||
|
||||
impl RemoveUserFromGroupComponent {
|
||||
fn submit_remove_group(&mut self) {
|
||||
fn submit_remove_group(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<RemoveUserFromGroup, _>(
|
||||
ctx,
|
||||
remove_user_from_group::Variables {
|
||||
user: self.common.username.clone(),
|
||||
group: self.common.group_id,
|
||||
user: ctx.props().username.clone(),
|
||||
group: ctx.props().group_id,
|
||||
},
|
||||
Msg::RemoveGroupResponse,
|
||||
"Error trying to initiate removing the user from a group",
|
||||
@@ -67,30 +71,28 @@ impl Component for RemoveUserFromGroupComponent {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update_and_report_error(
|
||||
self,
|
||||
ctx,
|
||||
msg,
|
||||
self.common.on_error.clone(),
|
||||
ctx.props().on_error.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|_| Msg::SubmitRemoveGroup)}>
|
||||
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, NavButton},
|
||||
components::router::{AppRoute, Link},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
@@ -31,7 +31,11 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::Submit => {
|
||||
@@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
|
||||
}
|
||||
let FormModel { username } = self.form.model();
|
||||
self.common.call_backend(
|
||||
HostService::reset_password_step1,
|
||||
&username,
|
||||
ctx,
|
||||
HostService::reset_password_step1(username),
|
||||
Msg::PasswordResetResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::PasswordResetResponse(response) => {
|
||||
@@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
ResetPasswordStep1Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
just_succeeded: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
self.just_succeeded = false;
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
@@ -95,11 +96,11 @@ impl Component for ResetPasswordStep1Form {
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
form={&self.form}
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
placeholder="Username or email"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
</div>
|
||||
{ if self.just_succeeded {
|
||||
html! {
|
||||
@@ -111,23 +112,24 @@ impl Component for ResetPasswordStep1Form {
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-check-circle me-2"/>
|
||||
{"Reset password"}
|
||||
</button>
|
||||
<NavButton
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::Login>
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::Login}>
|
||||
{"Back"}
|
||||
</NavButton>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-danger mb-2">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use lldap_auth::{
|
||||
opaque::client::registration as opaque_registration,
|
||||
password_reset::ServerPasswordResetResponse, registration,
|
||||
@@ -14,10 +17,7 @@ use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
use yew_form_derive::Model;
|
||||
use yew_router::{
|
||||
agent::{RouteAgentDispatcher, RouteRequest},
|
||||
route::Route,
|
||||
};
|
||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
@@ -33,7 +33,6 @@ pub struct ResetPasswordStep2Form {
|
||||
form: Form<FormModel>,
|
||||
username: Option<String>,
|
||||
opaque_data: Option<opaque_registration::ClientRegistration>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Properties)]
|
||||
@@ -50,11 +49,15 @@ pub enum Msg {
|
||||
}
|
||||
|
||||
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
use anyhow::Context;
|
||||
match msg {
|
||||
Msg::ValidateTokenResponse(response) => {
|
||||
self.username = Some(response?.user_id);
|
||||
self.common.cancel_task();
|
||||
Ok(true)
|
||||
}
|
||||
Msg::FormUpdate => Ok(true),
|
||||
@@ -65,18 +68,18 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let new_password = self.form.model().password;
|
||||
let registration_start_request =
|
||||
opaque_registration::start_registration(&new_password, &mut rng)
|
||||
opaque_registration::start_registration(new_password.as_bytes(), &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.username.clone().unwrap(),
|
||||
username: self.username.as_ref().unwrap().into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = Some(registration_start_request.state);
|
||||
self.common.call_backend(
|
||||
HostService::register_start,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_start(req),
|
||||
Msg::RegistrationStartResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::RegistrationStartResponse(res) => {
|
||||
@@ -94,17 +97,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
registration_upload: registration_finish.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
HostService::register_finish,
|
||||
req,
|
||||
ctx,
|
||||
HostService::register_finish(req),
|
||||
Msg::RegistrationFinishResponse,
|
||||
)?;
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Msg::RegistrationFinishResponse(response) => {
|
||||
self.common.cancel_task();
|
||||
if response.is_ok() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
|
||||
ctx.link().history().unwrap().push(AppRoute::Login);
|
||||
}
|
||||
response?;
|
||||
Ok(true)
|
||||
@@ -121,35 +122,28 @@ impl Component for ResetPasswordStep2Form {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = ResetPasswordStep2Form {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<FormModel>::new(FormModel::default()),
|
||||
opaque_data: None,
|
||||
route_dispatcher: RouteAgentDispatcher::new(),
|
||||
username: None,
|
||||
};
|
||||
let token = component.common.token.clone();
|
||||
component
|
||||
.common
|
||||
.call_backend(
|
||||
HostService::reset_password_step2,
|
||||
&token,
|
||||
Msg::ValidateTokenResponse,
|
||||
)
|
||||
.unwrap();
|
||||
let token = ctx.props().token.clone();
|
||||
component.common.call_backend(
|
||||
ctx,
|
||||
HostService::reset_password_step2(token),
|
||||
Msg::ValidateTokenResponse,
|
||||
);
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
match (&self.username, &self.common.error) {
|
||||
(None, None) => {
|
||||
return html! {
|
||||
@@ -158,68 +152,44 @@ impl Component for ResetPasswordStep2Form {
|
||||
}
|
||||
(None, Some(e)) => {
|
||||
return html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
<>
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
<Link
|
||||
classes="btn-link btn"
|
||||
disabled={self.common.is_task_running()}
|
||||
to={AppRoute::Login}>
|
||||
{"Back"}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
<h2>{"Reset your password"}</h2>
|
||||
<form
|
||||
class="form">
|
||||
<div class="form-group row">
|
||||
<label for="new_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"New password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="confirm_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"Confirm password*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
form=&self.form
|
||||
field_name="confirm_password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput=self.common.callback(|_| Msg::FormUpdate) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("confirm_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mt-2">
|
||||
<button
|
||||
class="btn btn-primary col-sm-1 col-form-label"
|
||||
type="submit"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<form class="form">
|
||||
<Field<FormModel>
|
||||
label="New password"
|
||||
required=true
|
||||
form={&self.form}
|
||||
field_name="password"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Field<FormModel>
|
||||
label="Confirm password"
|
||||
required=true
|
||||
form={&self.form}
|
||||
field_name="confirm_password"
|
||||
autocomplete="new-password"
|
||||
input_type="password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
use yew_router::{
|
||||
components::{RouterAnchor, RouterButton},
|
||||
Switch,
|
||||
};
|
||||
use yew_router::Routable;
|
||||
|
||||
#[derive(Switch, Debug, Clone)]
|
||||
#[derive(Routable, Debug, Clone, PartialEq)]
|
||||
pub enum AppRoute {
|
||||
#[to = "/login"]
|
||||
#[at("/login")]
|
||||
Login,
|
||||
#[to = "/reset-password/step1"]
|
||||
#[at("/reset-password/step1")]
|
||||
StartResetPassword,
|
||||
#[to = "/reset-password/step2/{token}"]
|
||||
FinishResetPassword(String),
|
||||
#[to = "/users/create"]
|
||||
#[at("/reset-password/step2/:token")]
|
||||
FinishResetPassword { token: String },
|
||||
#[at("/users/create")]
|
||||
CreateUser,
|
||||
#[to = "/users"]
|
||||
#[at("/users")]
|
||||
ListUsers,
|
||||
#[to = "/user/{user_id}/password"]
|
||||
ChangePassword(String),
|
||||
#[to = "/user/{user_id}"]
|
||||
UserDetails(String),
|
||||
#[to = "/groups/create"]
|
||||
#[at("/user/:user_id/password")]
|
||||
ChangePassword { user_id: String },
|
||||
#[at("/user/:user_id")]
|
||||
UserDetails { user_id: String },
|
||||
#[at("/groups/create")]
|
||||
CreateGroup,
|
||||
#[to = "/groups"]
|
||||
#[at("/groups")]
|
||||
ListGroups,
|
||||
#[to = "/group/{group_id}"]
|
||||
GroupDetails(i64),
|
||||
#[to = "/"]
|
||||
#[at("/group/:group_id")]
|
||||
GroupDetails { group_id: i64 },
|
||||
#[at("/user-attributes")]
|
||||
ListUserSchema,
|
||||
#[at("/user-attributes/create")]
|
||||
CreateUserAttribute,
|
||||
#[at("/group-attributes")]
|
||||
ListGroupSchema,
|
||||
#[at("/group-attributes/create")]
|
||||
CreateGroupAttribute,
|
||||
#[at("/")]
|
||||
Index,
|
||||
}
|
||||
|
||||
pub type Link = RouterAnchor<AppRoute>;
|
||||
|
||||
pub type NavButton = RouterButton<AppRoute>;
|
||||
pub type Link = yew_router::components::Link<AppRoute>;
|
||||
pub type Redirect = yew_router::components::Redirect<AppRoute>;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use yew::{html::ChangeData, prelude::*};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
pub struct Select {
|
||||
link: ComponentLink<Self>,
|
||||
props: SelectProps,
|
||||
node_ref: NodeRef,
|
||||
}
|
||||
|
||||
@@ -14,100 +11,70 @@ pub struct SelectProps {
|
||||
}
|
||||
|
||||
pub enum SelectMsg {
|
||||
OnSelectChange(ChangeData),
|
||||
OnSelectChange,
|
||||
}
|
||||
|
||||
impl Select {
|
||||
fn get_nth_child_props(&self, nth: i32) -> Option<SelectOptionProps> {
|
||||
fn get_nth_child_props(&self, ctx: &Context<Self>, nth: i32) -> Option<SelectOptionProps> {
|
||||
if nth == -1 {
|
||||
return None;
|
||||
}
|
||||
self.props
|
||||
ctx.props()
|
||||
.children
|
||||
.iter()
|
||||
.nth(nth as usize)
|
||||
.map(|child| child.props)
|
||||
.map(|child| (*child.props).clone())
|
||||
}
|
||||
|
||||
fn send_selection_update(&self) {
|
||||
fn send_selection_update(&self, ctx: &Context<Self>) {
|
||||
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
|
||||
self.props
|
||||
ctx.props()
|
||||
.on_selection_change
|
||||
.emit(self.get_nth_child_props(select_node.selected_index()))
|
||||
.emit(self.get_nth_child_props(ctx, select_node.selected_index()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Select {
|
||||
type Message = SelectMsg;
|
||||
type Properties = SelectProps;
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
node_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, _first_render: bool) {
|
||||
self.send_selection_update();
|
||||
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
|
||||
self.send_selection_update(ctx);
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
let SelectMsg::OnSelectChange(data) = msg;
|
||||
match data {
|
||||
ChangeData::Select(_) => self.send_selection_update(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
fn update(&mut self, ctx: &Context<Self>, _: Self::Message) -> bool {
|
||||
self.send_selection_update(ctx);
|
||||
false
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.children.neq_assign(props.children)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<select
|
||||
ref=self.node_ref.clone()
|
||||
disabled=self.props.children.is_empty()
|
||||
onchange=self.link.callback(SelectMsg::OnSelectChange)>
|
||||
{ self.props.children.clone() }
|
||||
<select class="form-select"
|
||||
ref={self.node_ref.clone()}
|
||||
disabled={ctx.props().children.is_empty()}
|
||||
onchange={ctx.link().callback(|_| SelectMsg::OnSelectChange)}>
|
||||
{ ctx.props().children.clone() }
|
||||
</select>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SelectOption {
|
||||
props: SelectOptionProps,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct SelectOptionProps {
|
||||
pub value: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Component for SelectOption {
|
||||
type Message = ();
|
||||
type Properties = SelectOptionProps;
|
||||
|
||||
fn create(props: Self::Properties, _: ComponentLink<Self>) -> Self {
|
||||
Self { props }
|
||||
}
|
||||
|
||||
fn update(&mut self, _: Self::Message) -> ShouldRender {
|
||||
false
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.props.neq_assign(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
html! {
|
||||
<option value=self.props.value.clone()>
|
||||
{&self.props.text}
|
||||
</option>
|
||||
}
|
||||
#[function_component(SelectOption)]
|
||||
pub fn select_option(props: &SelectOptionProps) -> Html {
|
||||
html! {
|
||||
<option value={props.value.clone()}>
|
||||
{&props.text}
|
||||
</option>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ use crate::{
|
||||
components::{
|
||||
add_user_to_group::AddUserToGroupComponent,
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link, NavButton},
|
||||
router::{AppRoute, Link},
|
||||
user_details_form::UserDetailsForm,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
}, infra::{schema::AttributeType, common_component::{CommonComponent, CommonComponentParts}},
|
||||
convert_attribute_type
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
@@ -22,6 +22,10 @@ pub struct GetUserDetails;
|
||||
|
||||
pub type User = get_user_details::GetUserDetailsUser;
|
||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||
pub type AttributeSchema = get_user_details::GetUserDetailsUserAttributesSchema;
|
||||
|
||||
convert_attribute_type!(get_user_details::AttributeType);
|
||||
|
||||
pub struct UserDetails {
|
||||
common: CommonComponentParts<Self>,
|
||||
@@ -47,7 +51,7 @@ pub struct Props {
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetails> for UserDetails {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserDetailsResponse(response) => match response {
|
||||
Ok(user) => self.user = Some(user.user),
|
||||
@@ -77,10 +81,11 @@ impl CommonComponent<UserDetails> for UserDetails {
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn get_user_details(&mut self) {
|
||||
fn get_user_details(&mut self, ctx: &Context<Self>) {
|
||||
self.common.call_graphql::<GetUserDetails, _>(
|
||||
ctx,
|
||||
get_user_details::Variables {
|
||||
id: self.common.username.clone(),
|
||||
id: ctx.props().username.clone(),
|
||||
},
|
||||
Msg::UserDetailsResponse,
|
||||
"Error trying to fetch user details",
|
||||
@@ -99,24 +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 display_name = group.display_name.clone();
|
||||
html! {
|
||||
<tr key="groupRow_".to_string() + &display_name>
|
||||
{if self.common.is_admin { html! {
|
||||
<tr key={"groupRow_".to_string() + &display_name}>
|
||||
{if ctx.props().is_admin { html! {
|
||||
<>
|
||||
<td>
|
||||
<Link route=AppRoute::GroupDetails(group.id)>
|
||||
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
|
||||
{&group.display_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<RemoveUserFromGroupComponent
|
||||
username=u.id.clone()
|
||||
group_id=group.id
|
||||
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={u.id.clone()}
|
||||
group_id={group.id}
|
||||
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</>
|
||||
} } else { html! {
|
||||
@@ -129,18 +135,18 @@ impl UserDetails {
|
||||
<>
|
||||
<h5 class="row m-3 fw-bold">{"Group memberships"}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr key="headerRow">
|
||||
<th>{"Group"}</th>
|
||||
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
{ if ctx.props().is_admin { html!{ <th></th> }} else { html!{} }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{if u.groups.is_empty() {
|
||||
html! {
|
||||
<tr key="EmptyRow">
|
||||
<td>{"Not member of any group"}</td>
|
||||
<td>{"This user is not a member of any groups."}</td>
|
||||
</tr>
|
||||
}
|
||||
} else {
|
||||
@@ -153,14 +159,15 @@ impl UserDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_add_group_button(&self, u: &User) -> Html {
|
||||
if self.common.is_admin {
|
||||
fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
|
||||
let link = &ctx.link();
|
||||
if ctx.props().is_admin {
|
||||
html! {
|
||||
<AddUserToGroupComponent
|
||||
username=u.id.clone()
|
||||
groups=u.groups.clone()
|
||||
on_error=self.common.callback(Msg::OnError)
|
||||
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
|
||||
username={u.id.clone()}
|
||||
groups={u.groups.clone()}
|
||||
on_error={link.callback(Msg::OnError)}
|
||||
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
@@ -172,24 +179,20 @@ impl Component for UserDetails {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user: None,
|
||||
};
|
||||
table.get_user_details();
|
||||
table.get_user_details(ctx);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.user, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
@@ -197,17 +200,20 @@ impl Component for UserDetails {
|
||||
html! {
|
||||
<>
|
||||
<h3>{u.id.to_string()}</h3>
|
||||
<UserDetailsForm
|
||||
user=u.clone() />
|
||||
<div class="row justify-content-center">
|
||||
<NavButton
|
||||
route=AppRoute::ChangePassword(u.id.clone())
|
||||
classes="btn btn-primary col-auto">
|
||||
{"Change password"}
|
||||
</NavButton>
|
||||
<div class="d-flex flex-row-reverse">
|
||||
<Link
|
||||
to={AppRoute::ChangePassword{user_id: u.id.clone()}}
|
||||
classes="btn btn-secondary">
|
||||
<i class="bi-key me-2"></i>
|
||||
{"Modify password"}
|
||||
</Link>
|
||||
</div>
|
||||
{self.view_group_memberships(u)}
|
||||
{self.view_add_group_button(u)}
|
||||
<div>
|
||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||
</div>
|
||||
<UserDetailsForm user={u.clone()} />
|
||||
{self.view_group_memberships(ctx, u)}
|
||||
{self.view_add_group_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
components::user_details::User,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
components::{
|
||||
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 validator::HasLen;
|
||||
use validator_derive::Validate;
|
||||
use wasm_bindgen::JsCast;
|
||||
use yew::{prelude::*, services::ConsoleService};
|
||||
use web_sys::{FileList, FormData, HtmlFormElement, HtmlInputElement, InputEvent};
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Default)]
|
||||
use super::user_details::Attribute;
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
file: Option<web_sys::File>,
|
||||
file: Option<File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ToString for JsFile {
|
||||
fn to_string(&self) -> String {
|
||||
self.file
|
||||
.as_ref()
|
||||
.map(web_sys::File::name)
|
||||
.unwrap_or_else(String::new)
|
||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +50,6 @@ impl FromStr for JsFile {
|
||||
pub struct UserModel {
|
||||
#[validate(email)]
|
||||
email: String,
|
||||
#[validate(length(min = 1, message = "Display name is required"))]
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
@@ -64,18 +70,26 @@ pub struct UpdateUser;
|
||||
pub struct UserDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<UserModel>,
|
||||
avatar: JsFile,
|
||||
// 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.
|
||||
just_updated: bool,
|
||||
user: User,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
/// A form field changed.
|
||||
Update,
|
||||
/// A new file was selected.
|
||||
FileSelected(File),
|
||||
/// The "Submit" button was clicked.
|
||||
SubmitClicked,
|
||||
/// The "Clear" button for the avatar was clicked.
|
||||
ClearAvatarClicked,
|
||||
/// A picked file finished loading.
|
||||
FileLoaded(yew::services::reader::FileData),
|
||||
FileLoaded(String, Result<Vec<u8>>),
|
||||
/// We got the response from the server about our update message.
|
||||
UserUpdated(Result<update_user::ResponseData>),
|
||||
}
|
||||
@@ -87,55 +101,67 @@ pub struct Props {
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => {
|
||||
let window = web_sys::window().expect("no global `window` exists");
|
||||
let document = window.document().expect("should have a document on window");
|
||||
let input = document
|
||||
.get_element_by_id("avatarInput")
|
||||
.expect("Form field avatarInput should be present")
|
||||
.dyn_into::<web_sys::HtmlInputElement>()
|
||||
.expect("Should be an HtmlInputElement");
|
||||
ConsoleService::log("Form update");
|
||||
if let Some(files) = input.files() {
|
||||
ConsoleService::log("Got file list");
|
||||
if files.length() > 0 {
|
||||
ConsoleService::log("Got a file");
|
||||
let new_avatar = JsFile {
|
||||
file: files.item(0),
|
||||
contents: None,
|
||||
};
|
||||
if self.avatar.file.as_ref().map(|f| f.name())
|
||||
!= new_avatar.file.as_ref().map(|f| f.name())
|
||||
{
|
||||
if let Some(ref file) = new_avatar.file {
|
||||
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
|
||||
}
|
||||
self.avatar = new_avatar;
|
||||
}
|
||||
}
|
||||
Msg::Update => Ok(true),
|
||||
Msg::FileSelected(new_avatar) => {
|
||||
if self
|
||||
.avatar
|
||||
.as_ref()
|
||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||
!= Some(new_avatar.name())
|
||||
{
|
||||
let file_name = new_avatar.name();
|
||||
let link = ctx.link().clone();
|
||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||
link.send_message(Msg::FileLoaded(
|
||||
file_name,
|
||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||
))
|
||||
}));
|
||||
self.avatar = Some(JsFile {
|
||||
file: Some(new_avatar),
|
||||
contents: None,
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitClicked => self.submit_user_update_form(),
|
||||
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::FileLoaded(data) => {
|
||||
self.common.cancel_task();
|
||||
if let Some(file) = &self.avatar.file {
|
||||
if file.name() == data.name {
|
||||
if !is_valid_jpeg(data.content.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = JsFile::default();
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
self.avatar.contents = Some(data.content);
|
||||
return Ok(true);
|
||||
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)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,165 +174,127 @@ impl Component for UserDetailsForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let model = UserModel {
|
||||
email: props.user.email.clone(),
|
||||
display_name: props.user.display_name.clone(),
|
||||
first_name: props.user.first_name.clone(),
|
||||
last_name: props.user.last_name.clone(),
|
||||
email: ctx.props().user.email.clone(),
|
||||
display_name: ctx.props().user.display_name.clone(),
|
||||
first_name: ctx.props().user.first_name.clone(),
|
||||
last_name: ctx.props().user.last_name.clone(),
|
||||
};
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::new(model),
|
||||
avatar: JsFile::default(),
|
||||
avatar: None,
|
||||
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;
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<UserModel>;
|
||||
|
||||
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
|
||||
let avatar_string = avatar_base64.as_ref().unwrap_or(&self.common.user.avatar);
|
||||
let avatar_string = match &self.avatar {
|
||||
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! {
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="userId"
|
||||
<StaticValue label="User ID" id="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">
|
||||
{"User ID: "}
|
||||
{"Avatar: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="userId" class="form-constrol-static"><b>{&self.common.user.id}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<div class="col-4 col-form-label">
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||
alt="Avatar" />
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<input
|
||||
class="form-control"
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email*: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="email"
|
||||
autocomplete="email"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
<div class="row align-items-center">
|
||||
<div class="col-5">
|
||||
<input
|
||||
class="form-control"
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
Self::upload_files(input.files())
|
||||
})} />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-secondary col-auto"
|
||||
id="avatarClear"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
|
||||
{"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 class="form-group row mb-3">
|
||||
<label for="display_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Display Name*: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("display_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="first_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"First Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form=&self.form
|
||||
field_name="first_name"
|
||||
autocomplete="given-name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("first_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="last_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Last Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
form=&self.form
|
||||
field_name="last_name"
|
||||
autocomplete="family-name"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{&self.common.user.creation_date.date().naive_local()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="uuid"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{&self.common.user.uuid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
|
||||
{"Update"}
|
||||
</button>
|
||||
</div>
|
||||
{self.user.attributes.iter().map(get_custom_attribute_input).collect::<Vec<_>>()}
|
||||
<Submit
|
||||
text="Save changes"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {Msg::SubmitClicked})} />
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
@@ -314,37 +302,108 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div hidden=!self.just_updated>
|
||||
<span>{"User successfully updated!"}</span>
|
||||
<div hidden={!self.just_updated}>
|
||||
<div class="alert alert-success mt-4">{"User successfully updated!"}</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 {
|
||||
fn submit_user_update_form(&mut self) -> Result<bool> {
|
||||
ConsoleService::log("Submit");
|
||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
if !self.form.validate() {
|
||||
bail!("Invalid inputs");
|
||||
}
|
||||
ConsoleService::log("Valid inputs");
|
||||
if let JsFile {
|
||||
if let Some(JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} = &self.avatar
|
||||
}) = &self.avatar
|
||||
{
|
||||
bail!("Image file hasn't finished loading, try again");
|
||||
}
|
||||
ConsoleService::log("File is correctly loaded");
|
||||
let base_user = &self.common.user;
|
||||
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 {
|
||||
id: self.common.user.id.clone(),
|
||||
id: self.user.id.clone(),
|
||||
email: None,
|
||||
displayName: None,
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
avatar: None,
|
||||
removeAttributes: remove_names,
|
||||
insertAttributes: insert_attrs,
|
||||
};
|
||||
let default_user_input = user_input.clone();
|
||||
let model = self.form.model();
|
||||
@@ -361,15 +420,16 @@ impl UserDetailsForm {
|
||||
if base_user.last_name != model.last_name {
|
||||
user_input.lastName = Some(model.last_name);
|
||||
}
|
||||
user_input.avatar = maybe_to_base64(&self.avatar)?;
|
||||
if let Some(avatar) = &self.avatar {
|
||||
user_input.avatar = Some(to_base64(avatar)?);
|
||||
}
|
||||
// Nothing changed.
|
||||
if user_input == default_user_input {
|
||||
ConsoleService::log("No changes");
|
||||
return Ok(false);
|
||||
}
|
||||
let req = update_user::Variables { user: user_input };
|
||||
ConsoleService::log("Querying");
|
||||
self.common.call_graphql::<UpdateUser, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::UserUpdated,
|
||||
"Error trying to update user",
|
||||
@@ -378,23 +438,30 @@ impl UserDetailsForm {
|
||||
}
|
||||
|
||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||
self.common.cancel_task();
|
||||
match r {
|
||||
Err(e) => return Err(e),
|
||||
Ok(_) => {
|
||||
let model = self.form.model();
|
||||
self.common.user.email = model.email;
|
||||
self.common.user.display_name = model.display_name;
|
||||
self.common.user.first_name = model.first_name;
|
||||
self.common.user.last_name = model.last_name;
|
||||
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
|
||||
self.common.user.avatar = avatar;
|
||||
}
|
||||
self.just_updated = true;
|
||||
}
|
||||
};
|
||||
r?;
|
||||
let model = self.form.model();
|
||||
self.user.email = model.email;
|
||||
self.user.display_name = model.display_name;
|
||||
self.user.first_name = model.first_name;
|
||||
self.user.last_name = model.last_name;
|
||||
if let Some(avatar) = &self.avatar {
|
||||
self.user.avatar = Some(to_base64(avatar)?);
|
||||
}
|
||||
self.just_updated = 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 {
|
||||
@@ -403,12 +470,12 @@ fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn maybe_to_base64(file: &JsFile) -> Result<Option<String>> {
|
||||
fn to_base64(file: &JsFile) -> Result<String> {
|
||||
match file {
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: _,
|
||||
} => Ok(None),
|
||||
} => Ok(String::new()),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
@@ -420,7 +487,7 @@ fn maybe_to_base64(file: &JsFile) -> Result<Option<String>> {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
}
|
||||
Ok(Some(base64::encode(data)))
|
||||
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 {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ListUsersResponse(users) => {
|
||||
self.users = Some(users?.users.into_iter().collect());
|
||||
@@ -55,8 +55,9 @@ impl CommonComponent<UserTable> for UserTable {
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn get_users(&mut self, req: Option<RequestFilter>) {
|
||||
fn get_users(&mut self, ctx: &Context<Self>, req: Option<RequestFilter>) {
|
||||
self.common.call_graphql::<ListUsersQuery, _>(
|
||||
ctx,
|
||||
list_users_query::Variables { filters: req },
|
||||
Msg::ListUsersResponse,
|
||||
"Error trying to fetch users",
|
||||
@@ -68,27 +69,23 @@ impl Component for UserTable {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = UserTable {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
users: None,
|
||||
};
|
||||
table.get_users(None);
|
||||
table.get_users(ctx, None);
|
||||
table
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_users()}
|
||||
{self.view_users(ctx)}
|
||||
{self.view_errors()}
|
||||
</div>
|
||||
}
|
||||
@@ -96,11 +93,11 @@ impl Component for UserTable {
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
fn view_users(&self) -> Html {
|
||||
fn view_users(&self, ctx: &Context<Self>) -> Html {
|
||||
let make_table = |users: &Vec<User>| {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"User ID"}</th>
|
||||
@@ -113,7 +110,7 @@ impl UserTable {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.iter().map(|u| self.view_user(u)).collect::<Vec<_>>()}
|
||||
{users.iter().map(|u| self.view_user(ctx, u)).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -125,20 +122,21 @@ impl UserTable {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user(&self, user: &User) -> Html {
|
||||
fn view_user(&self, ctx: &Context<Self>, user: &User) -> Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<tr key=user.id.clone()>
|
||||
<td><Link route=AppRoute::UserDetails(user.id.clone())>{&user.id}</Link></td>
|
||||
<tr key={user.id.clone()}>
|
||||
<td><Link to={AppRoute::UserDetails{user_id: user.id.clone()}}>{&user.id}</Link></td>
|
||||
<td>{&user.email}</td>
|
||||
<td>{&user.display_name}</td>
|
||||
<td>{&user.first_name}</td>
|
||||
<td>{&user.last_name}</td>
|
||||
<td>{&user.creation_date.date().naive_local()}</td>
|
||||
<td>{&user.creation_date.naive_local().date()}</td>
|
||||
<td>
|
||||
<DeleteUser
|
||||
username=user.id.clone()
|
||||
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
|
||||
on_error=self.common.callback(Msg::OnError)/>
|
||||
username={user.id.clone()}
|
||||
on_user_deleted={link.callback(Msg::OnUserDeleted)}
|
||||
on_error={link.callback(Msg::OnError)}/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,136 +1,93 @@
|
||||
use super::cookies::set_cookie;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gloo_net::http::{Method, Request};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{login, registration, JWTClaims};
|
||||
|
||||
use yew::callback::Callback;
|
||||
use yew::format::Json;
|
||||
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use web_sys::RequestCredentials;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HostService {}
|
||||
|
||||
fn get_default_options() -> FetchOptions {
|
||||
FetchOptions {
|
||||
credentials: Some(Credentials::SameOrigin),
|
||||
..FetchOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||
use jwt::*;
|
||||
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
|
||||
Ok(token.claims().clone())
|
||||
}
|
||||
|
||||
fn create_handler<Resp, CallbackResult, F>(
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
handler: F,
|
||||
) -> Callback<Response<Result<Resp>>>
|
||||
where
|
||||
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
|
||||
CallbackResult: 'static,
|
||||
{
|
||||
Callback::once(move |response: Response<Result<Resp>>| {
|
||||
let (meta, maybe_data) = response.into_parts();
|
||||
let message = maybe_data
|
||||
.context("Could not reach server")
|
||||
.and_then(|data| handler(meta.status, data));
|
||||
callback.emit(message)
|
||||
})
|
||||
enum RequestType<Body: Serialize> {
|
||||
Get,
|
||||
Post(Body),
|
||||
}
|
||||
|
||||
struct RequestBody<T>(T);
|
||||
const GET_REQUEST: RequestType<()> = RequestType::Get;
|
||||
|
||||
impl<'a, R> From<&'a R> for RequestBody<Json<&'a R>>
|
||||
where
|
||||
R: serde::ser::Serialize,
|
||||
{
|
||||
fn from(request: &'a R) -> Self {
|
||||
Self(Json(request))
|
||||
fn base_url() -> String {
|
||||
yew_router::utils::base_url().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn call_server<Body: Serialize>(
|
||||
url: &str,
|
||||
body: RequestType<Body>,
|
||||
error_message: &'static str,
|
||||
) -> Result<String> {
|
||||
let 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> {
|
||||
fn from(request: yew::format::Nothing) -> Self {
|
||||
Self(request)
|
||||
}
|
||||
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||
url: &str,
|
||||
request: RequestType<Body>,
|
||||
error_message: &'static str,
|
||||
) -> Result<CallbackResult>
|
||||
where
|
||||
CallbackResult: DeserializeOwned + 'static,
|
||||
{
|
||||
let data = call_server(url, request, error_message).await?;
|
||||
serde_json::from_str(&data).context("Could not parse response")
|
||||
}
|
||||
|
||||
fn call_server<Req, CallbackResult, F, RB>(
|
||||
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||
url: &str,
|
||||
request: RB,
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
request: RequestType<Body>,
|
||||
error_message: &'static str,
|
||||
parse_response: F,
|
||||
) -> Result<FetchTask>
|
||||
where
|
||||
F: Fn(String) -> Result<CallbackResult> + 'static,
|
||||
CallbackResult: 'static,
|
||||
RB: Into<RequestBody<Req>>,
|
||||
Req: Into<yew::format::Text>,
|
||||
{
|
||||
let request = {
|
||||
// If the request type is empty (if the size is 0), it's a get.
|
||||
if std::mem::size_of::<RB>() == 0 {
|
||||
Request::get(url)
|
||||
} else {
|
||||
Request::post(url)
|
||||
}
|
||||
}
|
||||
.header("Content-Type", "application/json")
|
||||
.body(request.into().0)?;
|
||||
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
|
||||
if status.is_success() {
|
||||
parse_response(data)
|
||||
} else {
|
||||
Err(anyhow!("{}[{}]: {}", error_message, status, data))
|
||||
}
|
||||
});
|
||||
FetchService::fetch_with_options(request, get_default_options(), handler)
|
||||
) -> Result<()> {
|
||||
call_server(url, request, error_message).await.map(|_| ())
|
||||
}
|
||||
|
||||
fn call_server_json_with_error_message<CallbackResult, RB, Req>(
|
||||
url: &str,
|
||||
request: RB,
|
||||
callback: Callback<Result<CallbackResult>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
where
|
||||
CallbackResult: serde::de::DeserializeOwned + 'static,
|
||||
RB: Into<RequestBody<Req>>,
|
||||
Req: Into<yew::format::Text>,
|
||||
{
|
||||
call_server(url, request, callback, error_message, |data: String| {
|
||||
serde_json::from_str(&data).context("Could not parse response")
|
||||
})
|
||||
}
|
||||
|
||||
fn call_server_empty_response_with_error_message<RB, Req>(
|
||||
url: &str,
|
||||
request: RB,
|
||||
callback: Callback<Result<()>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
where
|
||||
RB: Into<RequestBody<Req>>,
|
||||
Req: Into<yew::format::Text>,
|
||||
{
|
||||
call_server(
|
||||
url,
|
||||
request,
|
||||
callback,
|
||||
error_message,
|
||||
|_data: String| Ok(()),
|
||||
)
|
||||
fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, bool)> {
|
||||
let jwt_claims = get_claims_from_jwt(response.token.as_str()).context("Could not parse JWT")?;
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error setting cookie")
|
||||
}
|
||||
|
||||
impl HostService {
|
||||
pub fn graphql_query<QueryType>(
|
||||
pub async fn graphql_query<QueryType>(
|
||||
variables: QueryType::Variables,
|
||||
callback: Callback<Result<QueryType::ResponseData>>,
|
||||
error_message: &'static str,
|
||||
) -> Result<FetchTask>
|
||||
) -> Result<QueryType::ResponseData>
|
||||
where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
{
|
||||
@@ -147,143 +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);
|
||||
call_server(
|
||||
"/api/graphql",
|
||||
&request_body,
|
||||
callback,
|
||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||
&(base_url() + "/api/graphql"),
|
||||
RequestType::Post(request_body),
|
||||
error_message,
|
||||
parse_graphql_response,
|
||||
)
|
||||
.await
|
||||
.and_then(unwrap_graphql_response)
|
||||
}
|
||||
|
||||
pub fn login_start(
|
||||
pub async fn login_start(
|
||||
request: login::ClientLoginStartRequest,
|
||||
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/login/start",
|
||||
&request,
|
||||
callback,
|
||||
&(base_url() + "/auth/opaque/login/start"),
|
||||
RequestType::Post(request),
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn login_finish(
|
||||
request: login::ClientLoginFinishRequest,
|
||||
callback: Callback<Result<(String, bool)>>,
|
||||
) -> Result<FetchTask> {
|
||||
let set_cookies = |jwt_claims: JWTClaims| {
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
"/auth/opaque/login/finish",
|
||||
&request,
|
||||
callback,
|
||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
&(base_url() + "/auth/opaque/login/finish"),
|
||||
RequestType::Post(request),
|
||||
"Could not finish authentication",
|
||||
parse_token,
|
||||
)
|
||||
.await
|
||||
.and_then(set_cookies_from_jwt)
|
||||
}
|
||||
|
||||
pub fn register_start(
|
||||
pub async fn register_start(
|
||||
request: registration::ClientRegistrationStartRequest,
|
||||
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/register/start",
|
||||
&request,
|
||||
callback,
|
||||
&(base_url() + "/auth/opaque/register/start"),
|
||||
RequestType::Post(request),
|
||||
"Could not start registration: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn register_finish(
|
||||
pub async fn register_finish(
|
||||
request: registration::ClientRegistrationFinishRequest,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/opaque/register/finish",
|
||||
&request,
|
||||
callback,
|
||||
&(base_url() + "/auth/opaque/register/finish"),
|
||||
RequestType::Post(request),
|
||||
"Could not finish registration",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
|
||||
let set_cookies = |jwt_claims: JWTClaims| {
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
"/auth/refresh",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
pub async fn refresh() -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
&(base_url() + "/auth/refresh"),
|
||||
GET_REQUEST,
|
||||
"Could not start authentication: ",
|
||||
parse_token,
|
||||
)
|
||||
.await
|
||||
.and_then(set_cookies_from_jwt)
|
||||
}
|
||||
|
||||
// The `_request` parameter is to make it the same shape as the other functions.
|
||||
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
|
||||
pub async fn logout() -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/logout",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
&(base_url() + "/auth/logout"),
|
||||
GET_REQUEST,
|
||||
"Could not logout",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn reset_password_step1(
|
||||
username: &str,
|
||||
callback: Callback<Result<()>>,
|
||||
) -> Result<FetchTask> {
|
||||
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&format!("/auth/reset/step1/{}", username),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
&format!(
|
||||
"{}/auth/reset/step1/{}",
|
||||
base_url(),
|
||||
url_escape::encode_query(&username)
|
||||
),
|
||||
RequestType::Post(""),
|
||||
"Could not initiate password reset",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn reset_password_step2(
|
||||
token: &str,
|
||||
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
|
||||
) -> Result<FetchTask> {
|
||||
pub async fn reset_password_step2(
|
||||
token: String,
|
||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("/auth/reset/step2/{}", token),
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
||||
GET_REQUEST,
|
||||
"Could not validate token",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn probe_password_reset() -> Result<bool> {
|
||||
Ok(gloo_net::http::Request::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,88 +21,62 @@
|
||||
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
|
||||
//! take care of error and task handling.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
marker::PhantomData,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::infra::api::HostService;
|
||||
use anyhow::{Error, Result};
|
||||
use gloo_console::error;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{
|
||||
prelude::*,
|
||||
services::{
|
||||
fetch::FetchTask,
|
||||
reader::{FileData, ReaderService, ReaderTask},
|
||||
ConsoleService,
|
||||
},
|
||||
};
|
||||
use yewtil::NeqAssign;
|
||||
use yew::prelude::*;
|
||||
|
||||
/// Trait required for common components.
|
||||
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
|
||||
/// Handle the incoming message. If an error is returned here, any running task will be
|
||||
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
|
||||
/// component will be refreshed.
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool>;
|
||||
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
|
||||
}
|
||||
|
||||
enum AnyTask {
|
||||
None,
|
||||
FetchTask(FetchTask),
|
||||
ReaderTask(ReaderTask),
|
||||
}
|
||||
|
||||
impl AnyTask {
|
||||
fn is_some(&self) -> bool {
|
||||
!matches!(self, AnyTask::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<FetchTask>> for AnyTask {
|
||||
fn from(task: Option<FetchTask>) -> Self {
|
||||
match task {
|
||||
Some(t) => AnyTask::FetchTask(t),
|
||||
None => AnyTask::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structure that contains the common parts needed by most components.
|
||||
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
|
||||
pub struct CommonComponentParts<C: CommonComponent<C>> {
|
||||
link: ComponentLink<C>,
|
||||
pub props: <C as Component>::Properties,
|
||||
pub error: Option<Error>,
|
||||
task: AnyTask,
|
||||
is_task_running: Arc<Mutex<bool>>,
|
||||
_phantom: PhantomData<C>,
|
||||
}
|
||||
|
||||
impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
pub fn create() -> Self {
|
||||
CommonComponentParts {
|
||||
error: None,
|
||||
is_task_running: Arc::new(Mutex::new(false)),
|
||||
_phantom: PhantomData::<C>,
|
||||
}
|
||||
}
|
||||
/// Whether there is a currently running task in the background.
|
||||
pub fn is_task_running(&self) -> bool {
|
||||
self.task.is_some()
|
||||
}
|
||||
|
||||
/// Cancel any background task.
|
||||
pub fn cancel_task(&mut self) {
|
||||
self.task = AnyTask::None;
|
||||
}
|
||||
|
||||
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
|
||||
Self {
|
||||
link,
|
||||
props,
|
||||
error: None,
|
||||
task: AnyTask::None,
|
||||
}
|
||||
*self.is_task_running.lock().unwrap()
|
||||
}
|
||||
|
||||
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
|
||||
/// [`CommonComponent::handle_msg`] and handle any resulting error.
|
||||
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
|
||||
pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
|
||||
com.mut_common().error = None;
|
||||
match com.handle_msg(msg) {
|
||||
match com.handle_msg(ctx, msg) {
|
||||
Err(e) => {
|
||||
ConsoleService::error(&e.to_string());
|
||||
error!(&e.to_string());
|
||||
com.mut_common().error = Some(e);
|
||||
com.mut_common().cancel_task();
|
||||
assert!(!*com.mut_common().is_task_running.lock().unwrap());
|
||||
true
|
||||
}
|
||||
Ok(b) => b,
|
||||
@@ -112,10 +86,11 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
/// Same as above, but the resulting error is instead passed to the reporting function.
|
||||
pub fn update_and_report_error(
|
||||
com: &mut C,
|
||||
ctx: &Context<C>,
|
||||
msg: <C as Component>::Message,
|
||||
report_fn: Callback<Error>,
|
||||
) -> ShouldRender {
|
||||
let should_render = Self::update(com, msg);
|
||||
) -> bool {
|
||||
let should_render = Self::update(com, ctx, msg);
|
||||
com.mut_common()
|
||||
.error
|
||||
.take()
|
||||
@@ -126,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
.unwrap_or(should_render)
|
||||
}
|
||||
|
||||
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
|
||||
/// properties have changed and return whether the component should update.
|
||||
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
|
||||
where
|
||||
<C as yew::Component>::Properties: std::cmp::PartialEq,
|
||||
{
|
||||
self.props.neq_assign(props)
|
||||
}
|
||||
|
||||
/// Create a callback from the link.
|
||||
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
|
||||
where
|
||||
M: Into<C::Message>,
|
||||
F: Fn(IN) -> M + 'static,
|
||||
{
|
||||
self.link.callback(function)
|
||||
}
|
||||
|
||||
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
|
||||
/// result. Returns whether _starting the call_ failed.
|
||||
pub fn call_backend<M, Req, Cb, Resp>(
|
||||
&mut self,
|
||||
method: M,
|
||||
req: Req,
|
||||
callback: Cb,
|
||||
) -> Result<()>
|
||||
/// result.
|
||||
pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
|
||||
where
|
||||
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
|
||||
Fut: Future<Output = Resp> + 'static,
|
||||
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?);
|
||||
Ok(())
|
||||
{
|
||||
let mut running = self.is_task_running.lock().unwrap();
|
||||
assert!(!*running);
|
||||
*running = true;
|
||||
}
|
||||
let is_task_running = self.is_task_running.clone();
|
||||
ctx.link().send_future(async move {
|
||||
let res = fut.await;
|
||||
*is_task_running.lock().unwrap() = false;
|
||||
callback(res)
|
||||
});
|
||||
}
|
||||
|
||||
/// Call the backend with a GraphQL query.
|
||||
@@ -165,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
/// `EnumCallback` should usually be left as `_`.
|
||||
pub fn call_graphql<QueryType, EnumCallback>(
|
||||
&mut self,
|
||||
ctx: &Context<C>,
|
||||
variables: QueryType::Variables,
|
||||
enum_callback: EnumCallback,
|
||||
error_message: &'static str,
|
||||
@@ -172,41 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = HostService::graphql_query::<QueryType>(
|
||||
variables,
|
||||
self.link.callback(enum_callback),
|
||||
error_message,
|
||||
)
|
||||
.map_err::<(), _>(|e| {
|
||||
ConsoleService::log(&e.to_string());
|
||||
self.error = Some(e);
|
||||
})
|
||||
.ok()
|
||||
.into();
|
||||
}
|
||||
|
||||
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
|
||||
where
|
||||
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
|
||||
{
|
||||
self.task = AnyTask::ReaderTask(ReaderService::read_file(
|
||||
file,
|
||||
self.link.callback_once(callback),
|
||||
)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
|
||||
type Target = <C as Component>::Properties;
|
||||
|
||||
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
|
||||
&self.props
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
|
||||
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
|
||||
&mut self.props
|
||||
self.call_backend(
|
||||
ctx,
|
||||
HostService::graphql_query::<QueryType>(variables, error_message),
|
||||
enum_callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) ->
|
||||
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
|
||||
})?;
|
||||
let cookie_string = format!(
|
||||
"{}={}; expires={}; sameSite=Strict; path=/",
|
||||
"{}={}; expires={}; sameSite=Strict; path={}/",
|
||||
cookie_name,
|
||||
value,
|
||||
expiration.to_rfc2822()
|
||||
expiration.to_rfc2822(),
|
||||
yew_router::utils::base_url().unwrap_or_default()
|
||||
);
|
||||
doc.set_cookie(&cookie_string)
|
||||
.map_err(|_| anyhow!("Could not set cookie"))
|
||||
@@ -53,7 +54,11 @@ pub fn get_cookie(cookie_name: &str) -> Result<Option<String>> {
|
||||
|
||||
pub fn delete_cookie(cookie_name: &str) -> Result<()> {
|
||||
if get_cookie(cookie_name)?.is_some() {
|
||||
set_cookie(cookie_name, "", &Utc.ymd(1970, 1, 1).and_hms(0, 0, 0))
|
||||
set_cookie(
|
||||
cookie_name,
|
||||
"",
|
||||
&Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(),
|
||||
)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 common_component;
|
||||
pub mod cookies;
|
||||
pub mod functional;
|
||||
pub mod graphql;
|
||||
pub mod modal;
|
||||
pub mod schema;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "bootstrap")]
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen]
|
||||
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||
pub type Modal;
|
||||
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||
pub fn new(e: web_sys::Element) -> Modal;
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||
pub fn show(this: &Modal);
|
||||
|
||||
#[wasm_bindgen(method)]
|
||||
#[wasm_bindgen(method, js_namespace = bootstrap)]
|
||||
pub fn hide(this: &Modal);
|
||||
}
|
||||
|
||||
66
app/src/infra/schema.rs
Normal file
66
app/src/infra/schema.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use anyhow::Result;
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
use validator::ValidationError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AttributeType {
|
||||
String,
|
||||
Integer,
|
||||
DateTime,
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
impl Display for AttributeType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AttributeType {
|
||||
type Err = ();
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"String" => Ok(AttributeType::String),
|
||||
"Integer" => Ok(AttributeType::Integer),
|
||||
"DateTime" => Ok(AttributeType::DateTime),
|
||||
"Jpeg" => Ok(AttributeType::Jpeg),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Macro to generate traits for converting between AttributeType and the
|
||||
// graphql generated equivalents.
|
||||
#[macro_export]
|
||||
macro_rules! convert_attribute_type {
|
||||
($source_type:ty) => {
|
||||
impl From<$source_type> for 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"]
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::nonstandard_macro_braces)]
|
||||
#![allow(clippy::uninlined_format_args)]
|
||||
#![allow(clippy::let_unit_value)]
|
||||
|
||||
pub mod components;
|
||||
pub mod infra;
|
||||
|
||||
@@ -8,7 +10,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run_app() -> Result<(), JsValue> {
|
||||
yew::start_app::<components::app::App>();
|
||||
yew::start_app::<components::app::AppContainer>();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js
|
||||
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
|
||||
10
app/static/main.js
Normal file
10
app/static/main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import init, { run_app } from '/pkg/lldap_app.js';
|
||||
async function main() {
|
||||
if(navigator.userAgent.indexOf('AppleWebKit') != -1) {
|
||||
await init('/pkg/lldap_app_bg.wasm');
|
||||
} else {
|
||||
await init('/pkg/lldap_app_bg.wasm.gz');
|
||||
}
|
||||
run_app();
|
||||
}
|
||||
main()
|
||||
@@ -1,4 +1,4 @@
|
||||
header h1 {
|
||||
header h2 {
|
||||
font-family: 'Bebas Neue', cursive;
|
||||
}
|
||||
|
||||
@@ -10,3 +10,23 @@ header h1 {
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
html.dark .bg-light {
|
||||
background-color: rgba(59,59,59,1) !important;
|
||||
}
|
||||
|
||||
html.dark a {
|
||||
color: #e1e1e1
|
||||
}
|
||||
|
||||
a {
|
||||
color: #212529
|
||||
}
|
||||
|
||||
html.dark .nav-link {
|
||||
color: #e1e1e1
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #212529
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
[package]
|
||||
name = "lldap_auth"
|
||||
version = "0.3.0-alpha.1"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
description = "Authentication protocol for LLDAP"
|
||||
edition = "2021"
|
||||
homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
name = "lldap_auth"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
version = "0.4.0"
|
||||
|
||||
[features]
|
||||
default = ["opaque_server", "opaque_client"]
|
||||
opaque_server = []
|
||||
opaque_client = []
|
||||
js = []
|
||||
sea_orm = ["dep:sea-orm"]
|
||||
|
||||
[dependencies]
|
||||
rust-argon2 = "0.8"
|
||||
curve25519-dalek = "3"
|
||||
digest = "0.9"
|
||||
generic-array = "*"
|
||||
generic-array = "0.14"
|
||||
rand = "0.8"
|
||||
serde = "*"
|
||||
sha2 = "0.9"
|
||||
@@ -27,10 +32,16 @@ version = "0.6"
|
||||
version = "*"
|
||||
features = [ "serde" ]
|
||||
|
||||
[dependencies.sea-orm]
|
||||
version= "0.12"
|
||||
default-features = false
|
||||
features = ["macros"]
|
||||
optional = true
|
||||
|
||||
# For WASM targets, use the JS getrandom.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
features = ["js"]
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
features = ["js"]
|
||||
|
||||
110
auth/src/lib.rs
110
auth/src/lib.rs
@@ -9,17 +9,17 @@ pub mod opaque;
|
||||
|
||||
/// The messages for the 3-step OPAQUE and simple login process.
|
||||
pub mod login {
|
||||
use super::*;
|
||||
use super::{types::UserId, *};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerData {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub server_login: opaque::server::login::ServerLogin,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientLoginStartRequest {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub login_start_request: opaque::server::login::CredentialRequest,
|
||||
}
|
||||
|
||||
@@ -39,14 +39,14 @@ pub mod login {
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientSimpleLoginRequest {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientSimpleLoginRequest {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ClientSimpleLoginRequest")
|
||||
.field("username", &self.username)
|
||||
.field("username", &self.username.as_str())
|
||||
.field("password", &"***********")
|
||||
.finish()
|
||||
}
|
||||
@@ -63,16 +63,16 @@ pub mod login {
|
||||
/// The messages for the 3-step OPAQUE registration process.
|
||||
/// It is used to reset a user's password.
|
||||
pub mod registration {
|
||||
use super::*;
|
||||
use super::{types::UserId, *};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerData {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientRegistrationStartRequest {
|
||||
pub username: String,
|
||||
pub username: UserId,
|
||||
pub registration_start_request: opaque::server::registration::RegistrationRequest,
|
||||
}
|
||||
|
||||
@@ -104,6 +104,100 @@ pub mod password_reset {
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
pub struct JWTClaims {
|
||||
pub exp: DateTime<Utc>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::types::UserId;
|
||||
use opaque_ke::ciphersuite::CipherSuite;
|
||||
use rand::{CryptoRng, RngCore};
|
||||
|
||||
@@ -77,10 +78,10 @@ pub mod client {
|
||||
pub use opaque_ke::ClientRegistrationFinishParameters;
|
||||
/// Initiate the registration negotiation.
|
||||
pub fn start_registration<R: RngCore + CryptoRng>(
|
||||
password: &str,
|
||||
password: &[u8],
|
||||
rng: &mut R,
|
||||
) -> AuthenticationResult<ClientRegistrationStartResult> {
|
||||
Ok(ClientRegistration::start(rng, password.as_bytes())?)
|
||||
Ok(ClientRegistration::start(rng, password)?)
|
||||
}
|
||||
|
||||
/// Finalize the registration negotiation.
|
||||
@@ -145,12 +146,12 @@ pub mod server {
|
||||
pub fn start_registration(
|
||||
server_setup: &ServerSetup,
|
||||
registration_request: RegistrationRequest,
|
||||
username: &str,
|
||||
username: &UserId,
|
||||
) -> AuthenticationResult<ServerRegistrationStartResult> {
|
||||
Ok(ServerRegistration::start(
|
||||
server_setup,
|
||||
registration_request,
|
||||
username.as_bytes(),
|
||||
username.as_str().as_bytes(),
|
||||
)?)
|
||||
}
|
||||
|
||||
@@ -178,14 +179,14 @@ pub mod server {
|
||||
server_setup: &ServerSetup,
|
||||
password_file: Option<ServerRegistration>,
|
||||
credential_request: CredentialRequest,
|
||||
username: &str,
|
||||
username: &UserId,
|
||||
) -> AuthenticationResult<ServerLoginStartResult> {
|
||||
Ok(ServerLogin::start(
|
||||
rng,
|
||||
server_setup,
|
||||
password_file,
|
||||
credential_request,
|
||||
username.as_bytes(),
|
||||
username.as_str().as_bytes(),
|
||||
ServerLoginStartParameters::default(),
|
||||
)?)
|
||||
}
|
||||
|
||||
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 "$@"
|
||||
@@ -26,9 +26,9 @@ Frontend:
|
||||
|
||||
Data storage:
|
||||
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
|
||||
* Currently only SQLite is supported (see
|
||||
https://github.com/launchbadge/sqlx/issues/1225 for what blocks us from
|
||||
supporting more SQL backends).
|
||||
* The main SQL DBs are supported: SQLite by default, MySQL, MariaDB, PostgreSQL
|
||||
(see [DB Migration](/database_migration.md) for how to migrate off of
|
||||
SQLite).
|
||||
|
||||
### Code organization
|
||||
|
||||
|
||||
BIN
docs/cookie.png
Normal file
BIN
docs/cookie.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
111
docs/database_migration.md
Normal file
111
docs/database_migration.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Migration
|
||||
|
||||
Existing servers can migrate from one database backend to another. This page includes guidance for migrating from SQLite - similar concepts apply when migrating from databases of other types.
|
||||
|
||||
NOTE: [pgloader](https://github.com/dimitri/pgloader) is a tool that can easily migrate to PostgreSQL from other databases. Consider it if your target database is PostgreSQL
|
||||
|
||||
The process is as follows:
|
||||
|
||||
1. Create empty schema on target database
|
||||
2. Stop/pause LLDAP and dump existing values
|
||||
3. Sanitize for target DB (not always required)
|
||||
4. Insert data into target
|
||||
5. Change LLDAP config to new target and restart
|
||||
|
||||
The steps below assume you already have PostgreSQL or MySQL set up with an empty database for LLDAP to use.
|
||||
|
||||
## Create schema on target
|
||||
|
||||
LLDAP has a command that will connect to a target database and initialize the
|
||||
schema. If running with docker, run the following command to use your active
|
||||
instance (this has the benefit of ensuring your container has access):
|
||||
|
||||
```sh
|
||||
docker exec -it <LLDAP container name> /app/lldap create_schema -d <Target database url>
|
||||
```
|
||||
|
||||
If it succeeds, you can proceed to the next step.
|
||||
|
||||
## Create a dump of existing data
|
||||
|
||||
We want to dump (almost) all existing values to some file - the exception being the `metadata` table (and sometimes
|
||||
the `sqlite_sequence` table, when it exists). Be sure to stop/pause LLDAP during this step, as some
|
||||
databases (SQLite in this example) will give an error if LLDAP is in the middle of a write. The dump should consist just INSERT
|
||||
statements. There are various ways to do this, but a simple enough way is filtering a
|
||||
whole database dump. This repo contains [a script](/scripts/sqlite_dump_commands.sh) to generate SQLite commands for creating an appropriate dump:
|
||||
|
||||
```sh
|
||||
./sqlite_dump_commands.sh | sqlite3 /path/to/lldap/config/users.db > /path/to/dump.sql
|
||||
```
|
||||
|
||||
## Sanitize data
|
||||
|
||||
Some databases might use different formats for some data - for example, PostgreSQL uses
|
||||
a different syntax for hex strings than SQLite. We also want to make sure inserts are done in
|
||||
a transaction in case one of the statements fail.
|
||||
|
||||
### To PostgreSQL
|
||||
|
||||
PostgreSQL uses a different hex string format. The command below should switch SQLite
|
||||
format to PostgreSQL format, and wrap it all in a transaction:
|
||||
|
||||
```sh
|
||||
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 '$aSELECT setval(pg_get_serial_sequence('\''groups'\'', '\''group_id'\''), COALESCE((SELECT MAX(group_id) FROM groups), 1));' \
|
||||
-e '$aCOMMIT;' /path/to/dump.sql
|
||||
```
|
||||
|
||||
### To MySQL
|
||||
|
||||
MySQL mostly cooperates, but it gets some errors if you don't escape the `groups` table. It also uses
|
||||
backticks to escape table name instead of quotes. Run the
|
||||
following command to wrap all table names in backticks for good measure, and wrap the inserts in
|
||||
a transaction:
|
||||
|
||||
```sh
|
||||
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
|
||||
-e '1s/^/START TRANSACTION;\n/' \
|
||||
-e '$aCOMMIT;' \
|
||||
-e '1 i\SET FOREIGN_KEY_CHECKS = 0;' /path/to/dump.sql
|
||||
```
|
||||
|
||||
### To MariaDB
|
||||
|
||||
While MariaDB is supposed to be identical to MySQL, it doesn't support timezone offsets on DATETIME
|
||||
strings. Use the following command to remove those and perform the additional MySQL sanitization:
|
||||
|
||||
```sh
|
||||
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;' \
|
||||
-e '1 i\SET FOREIGN_KEY_CHECKS = 0;' /path/to/dump.sql
|
||||
```
|
||||
|
||||
## Insert data
|
||||
|
||||
Insert the data generated from the previous step into the target database. If you encounter errors,
|
||||
you may need to manually tweak your dump, or make changed in LLDAP and recreate the dump.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
`psql -d <database> -U <username> -W < /path/to/dump.sql`
|
||||
|
||||
or
|
||||
|
||||
`psql -d <database> -U <username> -W -f /path/to/dump.sql`
|
||||
|
||||
### MySQL
|
||||
|
||||
`mysql -u <username> -p <database> < /path/to/dump.sql`
|
||||
|
||||
|
||||
## Switch to new database
|
||||
|
||||
Modify your `database_url` in `lldap_config.toml` (or `LLDAP_DATABASE_URL` in the env)
|
||||
to point to your new database (the same value used when generating schema). Restart
|
||||
LLDAP and check the logs to ensure there were no errors.
|
||||
|
||||
#### More details/examples can be seen in the CI process [here](https://raw.githubusercontent.com/lldap/lldap/main/.github/workflows/docker-build-static.yml), look for the job `lldap-database-migration-test`
|
||||
58
docs/migration_guides/v0.5.md
Normal file
58
docs/migration_guides/v0.5.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Migration from 0.4 to 0.5
|
||||
|
||||
Welcome! If you're here, it's probably that the migration from 0.4.x to 0.5
|
||||
didn't go smoothly for you. Don't worry, we can fix that.
|
||||
|
||||
## Multiple users with the same email
|
||||
|
||||
This is the most common case. You can see in the LLDAP logs that there are
|
||||
several users with the same email, and they are listed.
|
||||
|
||||
This is not allowed anymore in v0.5, to prevent a user from setting their email
|
||||
to someone else's email and gaining access to systems that identify by email.
|
||||
|
||||
The problem is that you currently have several users with the same email, so the
|
||||
constraint cannot be enforced.
|
||||
|
||||
### Step 1: Take a note of the users with duplicate emails
|
||||
|
||||
In the LLDAP logs when you tried to start v0.5+, you'll see some warnings with
|
||||
the list of users with the same emails. Take note of them.
|
||||
|
||||
### Step 2: Downgrade to v0.4.3
|
||||
|
||||
If using docker, switch to the `lldap/lldap:v0.4.3` image. Alternatively, grab
|
||||
the binaries at https://github.com/lldap/lldap/releases/tag/v0.4.3.
|
||||
|
||||
This downgrade is safe and supported.
|
||||
|
||||
### Step 3: Remove duplicate emails
|
||||
|
||||
Restart LLDAP with the v0.4.3 version, and using your notes from step 1, change
|
||||
the email of users with duplicate emails to make sure that each email is unique.
|
||||
|
||||
### Step 4: Upgrade again
|
||||
|
||||
You can now revert to the initial version.
|
||||
|
||||
## Multiple users/groups with the same UUID
|
||||
|
||||
This should be extremely rare. In this case, you'll need to find which users
|
||||
have the same UUID, revert to v0.4.3 to be able to apply the changes, and delete
|
||||
one of the duplicates.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What if I want several users to be controlled by the same email?
|
||||
|
||||
You can use plus codes to set "the same" email to several users, while ensuring
|
||||
that they can't identify as each other. For instance:
|
||||
|
||||
- Admin: `admin@example.com`
|
||||
- Read-only admin: `admin+readonly@example.com`
|
||||
- Jellyfin admin: `admin+jellyfin@example.com`
|
||||
|
||||
### I'm upgrading to a higher version than v0.5.
|
||||
|
||||
This guide is still relevant: you can use whatever later version in place of
|
||||
v0.5. You'll still need to revert to v0.4.3 to apply the changes.
|
||||
99
docs/scripting.md
Normal file
99
docs/scripting.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Scripting
|
||||
|
||||
Programmatically accessing LLDAP can be done either through the LDAP protocol,
|
||||
or via the GraphQL API.
|
||||
|
||||
## LDAP
|
||||
|
||||
Most _read-only_ queries about users and groups are supported. Anything not
|
||||
supported would be considered a missing feature or a bug.
|
||||
|
||||
Most _modification_ queries are not supported, except for creating users and
|
||||
changing the password (through the extended password operation). Those could be
|
||||
added in the future, on a case-by-case basis.
|
||||
|
||||
Most _meta_-queries about the LDAP server itself are not supported and are out
|
||||
of scope. That includes anything that touches the schema, for instance. LLDAP
|
||||
still supports basic RootDSE queries.
|
||||
|
||||
Anonymous bind is not supported.
|
||||
|
||||
## `lldap-cli`
|
||||
|
||||
There is a community-built CLI frontend,
|
||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli), that supports all
|
||||
(as of this writing) the operations possible. Getting information from the
|
||||
server, creating users, adding them to groups, creating new custom attributes
|
||||
and populating them, all of that is supported. It is currently the easiest way
|
||||
to script the interaction with LLDAP.
|
||||
|
||||
## GraphQL
|
||||
|
||||
The best way to interact with LLDAP programmatically is via the GraphQL
|
||||
interface. You can use any language that has a GraphQL library (most of them
|
||||
do), and use the [GraphQL Schema](../schema.graphql) to guide your queries.
|
||||
|
||||
### Getting a token
|
||||
|
||||
You'll need a JWT (authentication token) to issue GraphQL queries. Your view of
|
||||
the system will be limited by the rights of your user. In particular, regular
|
||||
users can only see themselves and the groups they belong to (but not other
|
||||
members of these groups, for instance).
|
||||
|
||||
#### Manually
|
||||
|
||||
Log in to the web front-end of LLDAP. Then open the developer tools (F12), find
|
||||
the "Storage > Cookies", and you'll find the "token" cookie with your JWT.
|
||||
|
||||

|
||||
|
||||
#### Automatically
|
||||
|
||||
The easiest way is to send a json POST request to `/auth/simple/login` with
|
||||
`{"username": "john", "password": "1234"}` in the body.
|
||||
Then you'll receive a JSON response with:
|
||||
|
||||
```
|
||||
{
|
||||
"token": "eYbat...",
|
||||
"refreshToken": "3bCka...",
|
||||
}
|
||||
```
|
||||
|
||||
### Using the token
|
||||
|
||||
You can use the token directly, either as a cookie, or as a bearer auth token
|
||||
(add an "Authorization" header with contents `"Bearer <token>"`).
|
||||
|
||||
The JWT is valid for 1 day (unless you log out explicitly).
|
||||
You can use the refresh token to query `/auth/refresh` and get another JWT. The
|
||||
refresh token is valid for 30 days.
|
||||
|
||||
### Testing your GraphQL queries
|
||||
|
||||
You can go to `/api/graphql/playground` to test your queries and explore the
|
||||
data in the playground. You'll need to provide the JWT in the headers:
|
||||
|
||||
```
|
||||
{ "Authorization": "Bearer abcdef123..." }
|
||||
```
|
||||
|
||||
Then you can enter your query, for instance:
|
||||
|
||||
```graphql
|
||||
{
|
||||
user(userId:"admin") {
|
||||
displayName
|
||||
}
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
users {
|
||||
id
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The schema is on the right, along with some basic docs.
|
||||
18
example_configs/apereo_cas_server.md
Normal file
18
example_configs/apereo_cas_server.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Configuration for Apereo CAS Server
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain, and hostname for your LLDAP server.
|
||||
|
||||
The `search-filter` provided here requires users to be members of the `cas_auth` group in LLDAP.
|
||||
|
||||
Configuration to use LDAP in e.g. `/etc/cas/config/standalone.yml`
|
||||
```
|
||||
cas:
|
||||
authn:
|
||||
ldap:
|
||||
- base-dn: dc=example,dc=com
|
||||
bind-credential: password
|
||||
bind-dn: uid=admin,ou=people,dc=example,dc=com
|
||||
ldap-url: ldap://ldap.example.com:3890
|
||||
search-filter: (&(objectClass=person)(memberOf=uid=cas_auth,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
|
||||
@@ -30,11 +30,11 @@ authentication_backend:
|
||||
additional_users_dn: ou=people
|
||||
# To allow sign in both with username and email, one can use a filter like
|
||||
# (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))
|
||||
users_filter: (&({username_attribute}={input})(objectClass=person))
|
||||
users_filter: "(&({username_attribute}={input})(objectClass=person))"
|
||||
# Set this to ou=groups, because all groups are stored in this ou
|
||||
additional_groups_dn: ou=groups
|
||||
# Only this filter is supported right now
|
||||
groups_filter: (member={dn})
|
||||
# The groups are not displayed in the UI, but this filter works.
|
||||
groups_filter: "(member={dn})"
|
||||
# The attribute holding the name of the group.
|
||||
group_name_attribute: cn
|
||||
# Email attribute
|
||||
|
||||
105
example_configs/authentik.md
Normal file
105
example_configs/authentik.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Name
|
||||
```
|
||||
lldap
|
||||
```
|
||||
|
||||
# Slug
|
||||
```
|
||||
lldap
|
||||
```
|
||||
- [x] Enabled
|
||||
- [x] Sync Users
|
||||
- [x] User password writeback
|
||||
- [x] Sync groups
|
||||
|
||||
# Connection settings
|
||||
|
||||
## Server URI
|
||||
```
|
||||
ldap://lldap:3890
|
||||
```
|
||||
|
||||
- [ ] Enable StartTLS
|
||||
|
||||
## TLS Verification Certificate
|
||||
```
|
||||
---------
|
||||
```
|
||||
|
||||
## Bind CN
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
## Bind Password
|
||||
```
|
||||
ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
## Base DN
|
||||
```
|
||||
dc=example,dc=com
|
||||
```
|
||||
|
||||
# LDAP Attribute mapping
|
||||
## User Property Mappings
|
||||
- [x] authentik default LDAP Mapping: mail
|
||||
- [x] authentik default LDAP Mapping: Name
|
||||
- [x] authentik default Active Directory Mapping: givenName
|
||||
- [ ] authentik default Active Directory Mapping: sAMAccountName
|
||||
- [x] authentik default Active Directory Mapping: sn
|
||||
- [ ] authentik default Active Directory Mapping: userPrincipalName
|
||||
- [x] authentik default OpenLDAP Mapping: cn
|
||||
- [x] authentik default OpenLDAP Mapping: uid
|
||||
|
||||
## Group Property Mappings
|
||||
- [ ] authentik default LDAP Mapping: mail
|
||||
- [ ] authentik default LDAP Mapping: Name
|
||||
- [ ] authentik default Active Directory Mapping: givenName
|
||||
- [ ] authentik default Active Directory Mapping: sAMAccountName
|
||||
- [ ] authentik default Active Directory Mapping: sn
|
||||
- [ ] authentik default Active Directory Mapping: userPrincipalName
|
||||
- [x] authentik default OpenLDAP Mapping: cn
|
||||
- [ ] authentik default OpenLDAP Mapping: uid
|
||||
|
||||
# Additional settings
|
||||
|
||||
## Group
|
||||
```
|
||||
---------
|
||||
```
|
||||
|
||||
## User path
|
||||
```
|
||||
LDAP/users
|
||||
```
|
||||
|
||||
## Addition User DN
|
||||
```
|
||||
ou=people
|
||||
```
|
||||
|
||||
## Addition Group DN
|
||||
```
|
||||
ou=groups
|
||||
```
|
||||
|
||||
## User object filter
|
||||
```
|
||||
(objectClass=person)
|
||||
```
|
||||
|
||||
## Group object filter
|
||||
```
|
||||
(objectClass=groupOfUniqueNames)
|
||||
```
|
||||
|
||||
## Group membership field
|
||||
```
|
||||
member
|
||||
```
|
||||
|
||||
## Object uniqueness field
|
||||
```
|
||||
uid
|
||||
```
|
||||
BIN
example_configs/bootstrap/bootstrap-example-log-1.jpeg
Normal file
BIN
example_configs/bootstrap/bootstrap-example-log-1.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user