You've already forked lldap
mirror of
https://github.com/lldap/lldap.git
synced 2026-04-05 12:32:57 +01:00
Compare commits
467 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a48b248b6d | |||
| 2ad634deda | |||
| 155bda6bbf | |||
| 7d1593e266 | |||
| 8c8df11250 | |||
| aa1384939b | |||
| 6f94134fdc | |||
| d1904a2759 | |||
| 02d92c3261 | |||
| 48058540ec | |||
| 618e3f3062 | |||
| cafd3732f0 | |||
| 8588d4b851 | |||
| 2f70e2e31f | |||
| a9d04b6bdf | |||
| c03f3b5498 | |||
| ac55dfedc4 | |||
| 62ae1d73fa | |||
| 469f35c12c | |||
| ee9fec71a5 | |||
| 9cbb0c99e2 | |||
| 81e985df48 | |||
| a136a68bf4 | |||
| 8f0022a9f1 | |||
| fc7b33e4b3 | |||
| a9b5147a30 | |||
| 4de069452f | |||
| e5c28a61d9 | |||
| c5e0441cae | |||
| a959a50e07 | |||
| ab4389fc5f | |||
| ddcbe383ab | |||
| eee42502f3 | |||
| 660301eb5f | |||
| 73f071ce89 | |||
| 28ef6e0c56 | |||
| a32c8baa25 | |||
| bf5b76269f | |||
| c09e5c451c | |||
| 1382c67de9 | |||
| 0f8f9e1244 | |||
| 9a83e68667 | |||
| 3f9880ec11 | |||
| 94007aee58 | |||
| 9e9d8e2ab5 | |||
| 18edd4eb7d | |||
| 3cdf2241ea | |||
| 9021066507 | |||
| fe063272bf | |||
| 59dee0115d | |||
| 622274cb1a | |||
| 4bad3a9e69 | |||
| 84fb9b0fd2 | |||
| 8a803bfb11 | |||
| f7fe0c6ea0 | |||
| 8f04843466 | |||
| 400beafb29 | |||
| 963e58bf1a | |||
| 176c49c78d | |||
| 3d5542996f | |||
| 4590463cdf | |||
| 85ce481e32 | |||
| f64f8625f1 | |||
| c68f9e7cab | |||
| 775c5c716d | |||
| 89cb59919b | |||
| 267f08f479 | |||
| b370360130 | |||
| 7438fe92cf | |||
| cd2694d7dc | |||
| 5e83ed8eb0 | |||
| c69957690e | |||
| 7ef2af8beb | |||
| 5c9897b156 | |||
| 0b720aa082 | |||
| 3e7277e77d | |||
| 5241626a3a | |||
| 363ef106e2 | |||
| 3c7e4c3dec | |||
| fa196a9fd9 | |||
| f02b365478 | |||
| 0b0e6ae2cd | |||
| da525fc99b | |||
| 78337bce72 | |||
| 87e9311a44 | |||
| 53e62ecf5a | |||
| 10d33a7537 | |||
| ada438398e | |||
| 8c65d8958a | |||
| f8cd7ad023 | |||
| 823adcefd0 | |||
| 5b120a5958 | |||
| c658666b3f | |||
| 7a5a88384d | |||
| 4eb4fae49c | |||
| 58b028ad5f | |||
| 612bce48ad | |||
| 1b5f6bfa66 | |||
| 5913d81a44 | |||
| cb9fd38271 | |||
| 97bcfd1a99 | |||
| 7330496a77 | |||
| 0baee7a120 | |||
| 0a5b2d4c46 | |||
| 9978111bec | |||
| 8e25e9b2a4 | |||
| 4d6402c838 | |||
| b4f636ded9 | |||
| 4018a6933c | |||
| bd29c7282d | |||
| 1f89059c84 | |||
| 74dbba0bdc | |||
| 3556e41612 | |||
| d38a2cd08b | |||
| db77a0f023 | |||
| 3d61c209d2 | |||
| 55de3ac329 | |||
| ee21d83056 | |||
| a49ddeaa02 | |||
| dbba4c4e26 | |||
| 0eef966c3e | |||
| cdf43f2a69 | |||
| 7450ff1028 | |||
| c3ae149ae3 | |||
| 0a05a091d8 | |||
| 6a2a5fe7f5 | |||
| 52f22c00c3 | |||
| 37a85b4c2e | |||
| 63f8b51c88 | |||
| c4aca0dad7 | |||
| b8f114bd43 | |||
| 31364da6d4 | |||
| 853c561314 | |||
| 0aa31a282a | |||
| 41e38234ed | |||
| ba9bcb3894 | |||
| e18f2af54f | |||
| 5afcdbda65 | |||
| ba93533790 | |||
| e4044b7415 | |||
| 26b25e7776 | |||
| 20ade89633 | |||
| 928559890a | |||
| 049e882c35 | |||
| f5f3091313 | |||
| 0a0f915ce6 | |||
| 5f42d423e3 | |||
| 2a226963ee | |||
| ca1c6ff645 | |||
| e22d17dca6 | |||
| f34fa1d701 | |||
| d854ace89f | |||
| 3c0359eb8a | |||
| b591539c8a | |||
| 5d2f168554 | |||
| cf0e9a01f1 | |||
| 86d15e831e | |||
| 8285e21ebb | |||
| 4c6cfeee9e | |||
| 37a683dcb2 | |||
| b5e87c7226 | |||
| dd0ba5975e | |||
| 1b26859141 | |||
| 417abc54e4 | |||
| 5cc489aafe | |||
| c01c7744c7 | |||
| 1b58ac61f4 | |||
| f46e5375df | |||
| 722464daf4 | |||
| 0799b6bc26 | |||
| f5fbb31e6e | |||
| 31a0cf5a4f | |||
| 33fb59f2f7 | |||
| fb43af1299 | |||
| f417427635 | |||
| 1f26262e13 | |||
| 42fccf4713 | |||
| 928faa4bcc | |||
| 3895a5050d | |||
| f92035b6fd | |||
| 37a10c871f | |||
| 8397d536d9 | |||
| acd39d20b1 | |||
| 0ddeab8caa | |||
| 64514ddfc6 | |||
| c47be779a3 | |||
| fea2ed5b79 | |||
| e982908768 | |||
| 713dbde4cb | |||
| 579dd5e1b6 | |||
| 3828ec7624 | |||
| b8c06ebd75 | |||
| 130d2552ac | |||
| 098745ebc9 | |||
| 95337e2cd8 | |||
| 143eb70bee | |||
| 35fe521cbe | |||
| c8601b9169 | |||
| 8f6c324de7 | |||
| f0fcc88f1d | |||
| c08ddecd32 | |||
| 4ebfd0525b | |||
| a190fe7ddf | |||
| df188ee83f | |||
| 52c917d967 | |||
| f01daae6a8 | |||
| 62b2afa283 | |||
| 305b272cdf | |||
| a95ac38083 | |||
| abfe2f3a17 | |||
| 11d766b2ba | |||
| 56eee6908e | |||
| dcb45d4f6b | |||
| a6eac55fc7 | |||
| 1c6646d8c5 | |||
| 362e968e00 | |||
| 17bcd7645b | |||
| dcba3d17dc | |||
| 1f3f73585b | |||
| 0c6a92a8fa | |||
| 120ad34f92 | |||
| a2ba71ac19 | |||
| 10a820f2a2 | |||
| 01f97f5ed4 | |||
| f14aa2284c | |||
| 65e2103365 | |||
| 5db0072cfa | |||
| 1d8d3eb73f | |||
| 97e4d90eb7 | |||
| 6cf0f6df06 | |||
| b1384818d2 | |||
| 3ec44a58be | |||
| 6f7bfca682 | |||
| 2c79a40a73 | |||
| 25c6d6c962 | |||
| 04b048dd47 | |||
| dc26f97117 | |||
| 09c5d9f925 | |||
| ee7f9c9f41 | |||
| fa9c503de7 | |||
| 4138963bee | |||
| 5a2a92bbda | |||
| 6aa9303339 | |||
| 049a360506 | |||
| b26de34e0d | |||
| 15c28110b5 | |||
| 83508a363c | |||
| 010eec22d3 | |||
| b33d56a459 | |||
| 6eb5b959bf | |||
| 6f46ffd1e4 | |||
| 73686224dd | |||
| 56ed37ef8a | |||
| 39e1a02255 | |||
| 4f050cded5 | |||
| 254a168e78 | |||
| 85b83aff5f | |||
| 199a80ca5b | |||
| f96868318a | |||
| 04b0fa0ae9 | |||
| 2e08c6a7ec | |||
| 892492815d | |||
| 2597a250f0 | |||
| f67f090bde | |||
| a97881477f | |||
| 8587fc38fd | |||
| 6d65a2546c | |||
| 7806ed34ff | |||
| 22623bfab1 | |||
| 2f20f63b41 | |||
| 87d825626c | |||
| 8cbad6d5bd | |||
| 8db7d8a46f | |||
| 533d1bcfd0 | |||
| 3d8aafaa9d | |||
| f93681239b | |||
| 13720c101c | |||
| a1eb708cf3 | |||
| 959bb907d8 | |||
| 22074f56d2 | |||
| 5c5b87d5af | |||
| f65a6f524a | |||
| 96f5b31e0c | |||
| 4955b7fac1 | |||
| 646fe32645 | |||
| fa9743be6a | |||
| 38c4296d62 | |||
| 1c65cd115e | |||
| 8f2391a792 | |||
| bb2654f9c2 | |||
| 770e934859 | |||
| cc0827f271 | |||
| 93f3057b8f | |||
| 206e98c986 | |||
| 28e6fa0f10 | |||
| d4b3b4649e | |||
| b78e093205 | |||
| c2eed8909a | |||
| b82a2d5705 | |||
| addd453287 | |||
| e308a5e9a1 | |||
| 1f2f034a48 | |||
| bd0a58b476 | |||
| 4adb636d53 | |||
| 6f905b1ca9 | |||
| 2ea17c04ba | |||
| 10609b25e9 | |||
| 9f8364ca1a | |||
| 56078c0b47 | |||
| 8b7852bf1c | |||
| c4be7f5b6f | |||
| 337101edea | |||
| dc140f1675 | |||
| f74f88f0c0 | |||
| 708d927e90 | |||
| 0d48b7f8c9 | |||
| f2b1e73929 | |||
| 997119cdcf | |||
| a147085a2f | |||
| f363ff9437 | |||
| b6e6269956 | |||
| ff0ea51121 | |||
| 9ac96e8c6e | |||
| 63f802648f | |||
| 1aba962cd3 | |||
| 06697a5305 | |||
| 5a5d5b1d0e | |||
| 2e0d65e665 | |||
| 2c54ad895d | |||
| 272c84c574 | |||
| 71d37b9e5e | |||
| c55e0f3bcf | |||
| f2946e6cf6 | |||
| f3e2f8c52d | |||
| 70d85524db | |||
| ec0737c58a | |||
| 33f50d13a2 | |||
| 5cd4499328 | |||
| a65ad14349 | |||
| 2ca5e9e720 | |||
| 4f72153bd4 | |||
| 829c3f2bb1 | |||
| a6481dde56 | |||
| 35146ac904 | |||
| d488802e68 | |||
| 927c79bb55 | |||
| 3b6f24dd17 | |||
| 8ab900dfce | |||
| 504227eb13 | |||
| 1b97435853 | |||
| 1fddd87470 | |||
| af8277dbbd | |||
| 609d0ddb7d | |||
| 3df42ae707 | |||
| 8f9520b640 | |||
| 7c9f61e2eb | |||
| 5275af8f96 | |||
| 0db41f6278 | |||
| 4574538c76 | |||
| 9d5714ee0b | |||
| c6ecf8d58a | |||
| 9e88bfe6b4 | |||
| 5bd81780b3 | |||
| 4fd71ff02f | |||
| f0046692b8 | |||
| 439fde434b | |||
| 2a5fd01439 | |||
| 2c398d0e8e | |||
| 93e9985a81 | |||
| ed3be02384 | |||
| 3fadfb1944 | |||
| 81204dcee5 | |||
| 39a75b2c35 | |||
| 8e1515c27b | |||
| ddfd719884 | |||
| 6f04530700 | |||
| caf67fdf2b | |||
| 034794d58d | |||
| e53ce92c96 | |||
| 630ac5fd8c | |||
| b269fa0fc7 | |||
| 208cc7192e | |||
| 80e9145a4f | |||
| 78d370d3f4 | |||
| f279a14693 | |||
| b54bf3c4d5 | |||
| 582abba793 | |||
| 94da42ffb9 | |||
| 08d3aef177 | |||
| 7671b61a6b | |||
| 47b308f9b7 | |||
| 1a5931c3df | |||
| b3d771e063 | |||
| 134796aa9f | |||
| 1598f096e9 | |||
| 99ed6eface | |||
| ce6bf7c548 | |||
| 5677ff798f | |||
| e47004097a | |||
| 5e3a4f3446 | |||
| 8e61ee60d5 | |||
| a426453d7f | |||
| 1ac9bd0e68 | |||
| a83c305e51 | |||
| 7b171cf59a | |||
| b237c71b99 | |||
| 2eff37684d | |||
| 836823a5cd | |||
| e1d4df0b04 | |||
| 70bbe7f5ad | |||
| 6d796df097 | |||
| 6cd6b412fe | |||
| 042429a11d | |||
| c440df631f | |||
| 3247ffc8ea | |||
| ef17c280b1 | |||
| d0cdfa97c7 | |||
| f0bbcfd2c8 | |||
| 08b7c6ce33 | |||
| 719708dfd0 | |||
| b82cb83318 | |||
| d9f4adcb0e | |||
| e5bc06a617 | |||
| af49871801 | |||
| 7d1f5abc13 | |||
| 31a8ba24a0 | |||
| 9e1b58d033 | |||
| 1acc8cd78c | |||
| 3140af63de | |||
| 829ebf59f7 | |||
| 4ce145bac2 | |||
| 6ef229f3d0 | |||
| 19b4fd520a | |||
| 70146e0b70 | |||
| a804368806 | |||
| 3ec42fffaa | |||
| 95727335a7 | |||
| 79f9a3a5c2 | |||
| 7daebc308b | |||
| 50017cff36 | |||
| f812c9e666 | |||
| 87a35af693 | |||
| 4c4a397f66 | |||
| d720a7812a | |||
| d2dec56cca | |||
| ab2da7b975 | |||
| 8f69e4badd | |||
| 5bd00f24a2 | |||
| ab9ee8d962 | |||
| 852e1586e7 | |||
| 23b388f3b8 | |||
| 22ae2c7124 | |||
| 5ad63d31d3 | |||
| d55d4487ed | |||
| 4283d27da6 | |||
| 4576cf9f2c | |||
| d1d5d38b32 | |||
| e5ce98c874 | |||
| 96b7dbb1c5 | |||
| 9408b12bc7 | |||
| 4e85a4718f | |||
| d1f1eb8e80 | |||
| da364746c4 | |||
| d672f68049 | |||
| dcca768b6c | |||
| ea69b4bead | |||
| 7b4188a376 |
@@ -0,0 +1,46 @@
|
||||
# docs: https://docs.coderabbit.ai/reference/yaml-template for full configuration options
|
||||
tone_instructions: "Be concise"
|
||||
|
||||
reviews:
|
||||
profile: "chill"
|
||||
high_level_summary: false
|
||||
review_status: false
|
||||
commit_status: false
|
||||
collapse_walkthrough: true
|
||||
changed_files_summary: false
|
||||
sequence_diagrams: false
|
||||
estimate_code_review_effort: false
|
||||
assess_linked_issues: false
|
||||
related_issues: false
|
||||
related_prs: false
|
||||
suggested_labels: false
|
||||
suggested_reviewers: false
|
||||
poem: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
auto_incremental_review: true
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: false
|
||||
unit_tests:
|
||||
enabled: false
|
||||
|
||||
pre_merge_checks:
|
||||
docstrings:
|
||||
mode: "off"
|
||||
title:
|
||||
mode: "off"
|
||||
description:
|
||||
mode: "off"
|
||||
issue_assessment:
|
||||
mode: "off"
|
||||
|
||||
chat:
|
||||
art: false
|
||||
auto_reply: false
|
||||
|
||||
knowledge_base:
|
||||
web_search:
|
||||
enabled: true
|
||||
code_guidelines:
|
||||
enabled: false
|
||||
@@ -1,7 +1,9 @@
|
||||
FROM rust:1.66
|
||||
FROM rust:1.89
|
||||
|
||||
ARG USERNAME=lldapdev
|
||||
ARG USER_UID=1000
|
||||
# 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
|
||||
@@ -21,4 +23,4 @@ RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
|
||||
|
||||
USER $USERNAME
|
||||
ENV CARGO_HOME=/home/$USERNAME/.cargo
|
||||
ENV SHELL=/bin/bash
|
||||
ENV SHELL=/bin/bash
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
{
|
||||
"name": "LLDAP dev",
|
||||
"build": { "dockerfile": "Dockerfile" },
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"rust-lang.rust-analyzer"
|
||||
],
|
||||
"settings": {
|
||||
"rust-analyzer.linkedProjects": [
|
||||
"./Cargo.toml"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/rust:1": {}
|
||||
},
|
||||
"forwardPorts": [
|
||||
3890,
|
||||
17170
|
||||
]
|
||||
],
|
||||
"remoteUser": "lldapdev"
|
||||
}
|
||||
|
||||
+9
-1
@@ -34,12 +34,20 @@ package.json
|
||||
.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
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
example-configs/** linguist-documentation
|
||||
example_configs/** linguist-documentation
|
||||
docs/** linguist-documentation
|
||||
*.md linguist-documentation
|
||||
lldap_config.docker_template.toml linguist-documentation
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
* @nitnelave
|
||||
@@ -0,0 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [lldap]
|
||||
|
||||
custom: ['https://bmc.link/nitnelave']
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+12
-4
@@ -1,12 +1,20 @@
|
||||
codecov:
|
||||
require_ci_to_pass: yes
|
||||
comment:
|
||||
layout: "diff,flags"
|
||||
require_changes: true
|
||||
require_base: true
|
||||
require_head: true
|
||||
layout: "condensed_header, diff, condensed_files"
|
||||
hide_project_coverage: true
|
||||
require_changes: "coverage_drop"
|
||||
coverage:
|
||||
range: "70...100"
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: "75%"
|
||||
threshold: 5
|
||||
ignore:
|
||||
- "app"
|
||||
- "docs"
|
||||
- "example_configs"
|
||||
- "migration-tool"
|
||||
- "scripts"
|
||||
- "set-password"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# LLDAP - Light LDAP implementation for authentication
|
||||
|
||||
LLDAP is a lightweight LDAP authentication server written in Rust with a WebAssembly frontend. It provides an opinionated, simplified LDAP interface for authentication and integrates with many popular services.
|
||||
|
||||
**ALWAYS reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.**
|
||||
|
||||
## Working Effectively
|
||||
|
||||
### Bootstrap and Build the Repository
|
||||
- Install dependencies: `sudo apt-get update && sudo apt-get install -y curl gzip binaryen`
|
||||
- Install Rust if not available: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` then `source ~/.cargo/env`
|
||||
- Install wasm-pack for frontend: `cargo install wasm-pack` -- takes 90 seconds. NEVER CANCEL. Set timeout to 180+ seconds.
|
||||
- Build entire workspace: `cargo build --workspace` -- takes 3-4 minutes. NEVER CANCEL. Set timeout to 300+ seconds.
|
||||
- Build release server binary: `cargo build --release -p lldap` -- takes 5-6 minutes. NEVER CANCEL. Set timeout to 420+ seconds.
|
||||
- Build frontend WASM: `./app/build.sh` -- takes 3-4 minutes including wasm-pack installation. NEVER CANCEL. Set timeout to 300+ seconds.
|
||||
|
||||
### Testing and Validation
|
||||
- Run all tests: `cargo test --workspace` -- takes 2-3 minutes. NEVER CANCEL. Set timeout to 240+ seconds.
|
||||
- Check formatting: `cargo fmt --all --check` -- takes <5 seconds.
|
||||
- Run linting: `cargo clippy --tests --all -- -D warnings` -- takes 60-90 seconds. NEVER CANCEL. Set timeout to 120+ seconds.
|
||||
- Export GraphQL schema: `./export_schema.sh` -- takes 70-80 seconds. NEVER CANCEL. Set timeout to 120+ seconds.
|
||||
|
||||
### Running the Application
|
||||
- **ALWAYS run the build steps first before starting the server.**
|
||||
- Start development server: `cargo run -- run --config-file <config_file>`
|
||||
- **CRITICAL**: Server requires a valid configuration file. Use `lldap_config.docker_template.toml` as reference.
|
||||
- **CRITICAL**: Avoid key conflicts by removing existing `server_key*` files when testing with `key_seed` in config.
|
||||
- Server binds to:
|
||||
- LDAP: port 3890 (configurable)
|
||||
- Web interface: port 17170 (configurable)
|
||||
- LDAPS: port 6360 (optional, disabled by default)
|
||||
|
||||
### Manual Validation Requirements
|
||||
- **ALWAYS test both LDAP and web interfaces after making changes.**
|
||||
- Test web interface: `curl -s http://localhost:17170/` should return HTML with "LLDAP Administration" title.
|
||||
- Test GraphQL API: `curl -s -X POST -H "Content-Type: application/json" -d '{"query": "query { __schema { queryType { name } } }"}' http://localhost:17170/api/graphql`
|
||||
- Run healthcheck: `cargo run -- healthcheck --config-file <config_file>` (requires running server)
|
||||
- **ALWAYS ensure server starts without errors and serves the web interface before considering changes complete.**
|
||||
|
||||
## Validation Scenarios
|
||||
|
||||
After making code changes, ALWAYS:
|
||||
1. **Build validation**: Run `cargo build --workspace` to ensure compilation succeeds.
|
||||
2. **Test validation**: Run `cargo test --workspace` to ensure existing functionality works.
|
||||
3. **Lint validation**: Run `cargo clippy --tests --all -- -D warnings` to catch potential issues.
|
||||
4. **Format validation**: Run `cargo fmt --all --check` to ensure code style compliance.
|
||||
5. **Frontend validation**: Run `./app/build.sh` to ensure WASM compilation succeeds.
|
||||
6. **Runtime validation**: Start the server and verify web interface accessibility.
|
||||
7. **Schema validation**: If GraphQL changes made, run `./export_schema.sh` to update schema.
|
||||
|
||||
### Test User Scenarios
|
||||
- **Login flow**: Access web interface at `http://localhost:17170`, attempt login with admin/password (default).
|
||||
- **LDAP binding**: Test LDAP connection on port 3890 with appropriate LDAP tools if available.
|
||||
- **Configuration changes**: Test with different configuration files to validate config parsing.
|
||||
|
||||
## Project Structure and Key Components
|
||||
|
||||
### Backend (Rust)
|
||||
- **Server**: `/server` - Main application binary
|
||||
- **Crates**: `/crates/*` - Modularized components:
|
||||
- `auth` - Authentication and OPAQUE protocol
|
||||
- `domain*` - Domain models and handlers
|
||||
- `ldap` - LDAP protocol implementation
|
||||
- `graphql-server` - GraphQL API server
|
||||
- `sql-backend-handler` - Database operations
|
||||
- `validation` - Input validation utilities
|
||||
|
||||
### Frontend (Rust + WASM)
|
||||
- **App**: `/app` - Yew-based WebAssembly frontend
|
||||
- **Build**: `./app/build.sh` - Compiles Rust to WASM using wasm-pack
|
||||
- **Assets**: `/app/static` - Static web assets
|
||||
|
||||
### Configuration and Deployment
|
||||
- **Config template**: `lldap_config.docker_template.toml` - Reference configuration
|
||||
- **Docker**: `Dockerfile` - Container build definition
|
||||
- **Scripts**:
|
||||
- `prepare-release.sh` - Cross-platform release builds
|
||||
- `export_schema.sh` - GraphQL schema export
|
||||
- `generate_secrets.sh` - Random secret generation
|
||||
- `scripts/bootstrap.sh` - User/group management script
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Making Backend Changes
|
||||
1. Edit Rust code in `/server` or `/crates`
|
||||
2. Run `cargo build --workspace` to test compilation
|
||||
3. Run `cargo test --workspace` to ensure tests pass
|
||||
4. Run `cargo clippy --tests --all -- -D warnings` to check for warnings
|
||||
5. If GraphQL schema affected, run `./export_schema.sh`
|
||||
6. Test by running server and validating functionality
|
||||
|
||||
### Making Frontend Changes
|
||||
1. Edit code in `/app/src`
|
||||
2. Run `./app/build.sh` to rebuild WASM package
|
||||
3. Start server and test web interface functionality
|
||||
4. Verify no JavaScript errors in browser console
|
||||
|
||||
### Adding New Dependencies
|
||||
- Backend: Add to appropriate `Cargo.toml` in `/server` or `/crates/*`
|
||||
- Frontend: Add to `/app/Cargo.toml`
|
||||
- **Always rebuild after dependency changes**
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
The repository uses GitHub Actions (`.github/workflows/rust.yml`):
|
||||
- **Build job**: Validates workspace compilation
|
||||
- **Test job**: Runs full test suite
|
||||
- **Clippy job**: Linting with warnings as errors
|
||||
- **Format job**: Code formatting validation
|
||||
- **Coverage job**: Code coverage analysis
|
||||
|
||||
**ALWAYS ensure your changes pass all CI checks by running equivalent commands locally.**
|
||||
|
||||
## Timing Expectations and Timeouts
|
||||
|
||||
| Command | Expected Time | Timeout Setting |
|
||||
|---------|---------------|-----------------|
|
||||
| `cargo build --workspace` | 3-4 minutes | 300+ seconds |
|
||||
| `cargo build --release -p lldap` | 5-6 minutes | 420+ seconds |
|
||||
| `cargo test --workspace` | 2-3 minutes | 240+ seconds |
|
||||
| `./app/build.sh` | 3-4 minutes | 300+ seconds |
|
||||
| `cargo clippy --tests --all -- -D warnings` | 60-90 seconds | 120+ seconds |
|
||||
| `./export_schema.sh` | 70-80 seconds | 120+ seconds |
|
||||
| `cargo install wasm-pack` | 90 seconds | 180+ seconds |
|
||||
|
||||
**NEVER CANCEL** any of these commands. Builds may take longer on slower systems.
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### Build Issues
|
||||
- **Missing wasm-pack**: Run `cargo install wasm-pack`
|
||||
- **Missing binaryen**: Run `sudo apt-get install -y binaryen` or disable wasm-opt
|
||||
- **Clippy warnings**: Fix all warnings as they are treated as errors in CI
|
||||
- **GraphQL schema mismatch**: Run `./export_schema.sh` to update schema
|
||||
|
||||
### Runtime Issues
|
||||
- **Key conflicts**: Remove `server_key*` files when using `key_seed` in config
|
||||
- **Port conflicts**: Check if ports 3890/17170 are available
|
||||
- **Database issues**: Ensure database URL in config is valid and accessible
|
||||
- **Asset missing**: Ensure frontend is built with `./app/build.sh`
|
||||
|
||||
### Development Environment
|
||||
- **Rust version**: Use stable Rust toolchain (2024 edition)
|
||||
- **System dependencies**: curl, gzip, build tools
|
||||
- **Database**: SQLite (default), MySQL, or PostgreSQL supported
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
Essential configuration parameters:
|
||||
- `ldap_base_dn`: LDAP base DN (e.g., "dc=example,dc=com")
|
||||
- `ldap_user_dn`: Admin user DN
|
||||
- `ldap_user_pass`: Admin password
|
||||
- `jwt_secret`: Secret for JWT tokens (generate with `./generate_secrets.sh`)
|
||||
- `key_seed`: Encryption key seed
|
||||
- `database_url`: Database connection string
|
||||
- `http_port`: Web interface port (default: 17170)
|
||||
- `ldap_port`: LDAP server port (default: 3890)
|
||||
|
||||
**Always use the provided config template as starting point for new configurations.**
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Copilot Setup Steps for LLDAP Development
|
||||
|
||||
steps:
|
||||
- name: Update package list
|
||||
run: sudo apt-get update
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get install -y curl gzip binaryen build-essential
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source ~/.cargo/env
|
||||
echo 'source ~/.cargo/env' >> ~/.bashrc
|
||||
|
||||
- name: Install wasm-pack for frontend builds
|
||||
run: |
|
||||
source ~/.cargo/env
|
||||
cargo install wasm-pack
|
||||
|
||||
- name: Verify installations
|
||||
run: |
|
||||
source ~/.cargo/env
|
||||
rustc --version
|
||||
cargo --version
|
||||
wasm-pack --version
|
||||
@@ -1,72 +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/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool target/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/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-migration-tool-bin/migration-tool target/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/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-gnueabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool target/migration-tool && \
|
||||
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/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/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
|
||||
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 \
|
||||
@@ -81,9 +15,20 @@ RUN set -eux; \
|
||||
\
|
||||
# verify the signature
|
||||
export GNUPGHOME="$(mktemp -d)"; \
|
||||
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
|
||||
for server in \
|
||||
hkps://keys.openpgp.org \
|
||||
ha.pool.sks-keyservers.net \
|
||||
hkp://p80.pool.sks-keyservers.net:80 \
|
||||
keyserver.ubuntu.com \
|
||||
hkp://keyserver.ubuntu.com:80 \
|
||||
pgp.mit.edu \
|
||||
; do \
|
||||
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
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
|
||||
@@ -93,22 +38,4 @@ RUN set -eux; \
|
||||
# verify that the binary works
|
||||
gosu --version; \
|
||||
gosu nobody true
|
||||
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
|
||||
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"]
|
||||
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/lldap_migration_tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apk add --no-cache tini ca-certificates bash tzdata jq curl jo && \
|
||||
addgroup -g $GID $USER && \
|
||||
adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
--home "$(pwd)" \
|
||||
--ingroup "$USER" \
|
||||
--no-create-home \
|
||||
--uid "$UID" \
|
||||
"$USER" && \
|
||||
mkdir -p /data && \
|
||||
chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
WORKDIR /app
|
||||
COPY scripts/bootstrap.sh ./
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM localhost:5000/lldap/lldap:alpine-base
|
||||
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
|
||||
USER $USER
|
||||
@@ -1,79 +1,45 @@
|
||||
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-migration-tool-bin/migration-tool target/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/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-migration-tool-bin/migration-tool target/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/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-gnueabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool target/migration-tool && \
|
||||
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/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/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 gosu 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"]
|
||||
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)"; \
|
||||
for i in 1 2 3; do \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends wget ca-certificates gnupg && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && break || sleep 5; \
|
||||
done; \
|
||||
\
|
||||
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)"; \
|
||||
for server in \
|
||||
hkps://keys.openpgp.org \
|
||||
ha.pool.sks-keyservers.net \
|
||||
hkp://p80.pool.sks-keyservers.net:80 \
|
||||
keyserver.ubuntu.com \
|
||||
hkp://keyserver.ubuntu.com:80 \
|
||||
pgp.mit.edu \
|
||||
; do \
|
||||
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
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
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/lldap_migration_tool && \
|
||||
chmod +x target/lldap_set_password && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
COPY web/index_local.html web/index.html
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/lldap_migration_tool /lldap/ && \
|
||||
cp target/lldap_set_password /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
WORKDIR /lldap
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM debian:bullseye-slim
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini openssl ca-certificates tzdata jq curl jo && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
|
||||
mkdir -p /data && chown $USER:$USER /data
|
||||
COPY --from=lldap --chown=$USER:$USER /lldap /app
|
||||
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
VOLUME ["/data"]
|
||||
WORKDIR /app
|
||||
COPY scripts/bootstrap.sh ./
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM localhost:5000/lldap/lldap:debian-base
|
||||
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
|
||||
USER $USER
|
||||
@@ -1,45 +1,41 @@
|
||||
# Keep tracking base image
|
||||
FROM rust:1.66-slim-bullseye
|
||||
FROM rust:1.89-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 gzip && \
|
||||
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/*
|
||||
|
||||
### 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 gzip && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
### armhf deps
|
||||
RUN dpkg --add-architecture armhf && \
|
||||
apt update && \
|
||||
apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross gzip && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rustup target add armv7-unknown-linux-gnueabihf
|
||||
|
||||
### Add musl-gcc aarch64 and x86_64
|
||||
|
||||
### 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 aarch64-unknown-linux-musl && \
|
||||
rustup target add armv7-unknown-linux-musleabihf && \
|
||||
rustup target add x86_64-unknown-freebsd
|
||||
|
||||
|
||||
CMD ["bash"]
|
||||
|
||||
@@ -24,13 +24,12 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
MSRV: "1.89.0"
|
||||
|
||||
### CI Docs
|
||||
|
||||
# build-ui , create/compile the web
|
||||
### install wasm
|
||||
### install rollup
|
||||
### run app/build.sh
|
||||
### upload artifacts
|
||||
|
||||
@@ -40,10 +39,10 @@ env:
|
||||
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
|
||||
# Look into .github/workflows/Dockerfile.dev for development image details #
|
||||
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
|
||||
# lldap/rust-dev #
|
||||
#######################################################################################
|
||||
### Cargo build
|
||||
### aarch64 and amd64 is musl based
|
||||
### armv7 is glibc based, musl had issue with time_t when cross compile https://github.com/rust-lang/libc/issues/1848
|
||||
# Cargo build
|
||||
### armv7, aarch64 and amd64 is musl based
|
||||
|
||||
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
||||
|
||||
@@ -51,12 +50,11 @@ env:
|
||||
### will run lldap with postgres, mariadb and sqlite backend, do selfcheck command.
|
||||
|
||||
# Build docker image
|
||||
### Triplet docker image arch with debian base
|
||||
### amd64 & aarch64 with alpine base
|
||||
### Triplet docker image arch with debian and alpine base
|
||||
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
|
||||
### Look into .github/workflows/Dockerfile.ci.debian or .github/workflowds/Dockerfile.ci.alpine
|
||||
|
||||
# create release artifacts
|
||||
# Create release artifacts
|
||||
### Fetch artifacts
|
||||
### Clean up web artifact
|
||||
### Setup folder structure
|
||||
@@ -86,11 +84,17 @@ jobs:
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||
container:
|
||||
image: nitnelave/rust-dev:latest
|
||||
image: lldap/rust-dev:latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
- uses: actions/cache@v3
|
||||
uses: actions/checkout@v6.0.2
|
||||
- name: Install Rust
|
||||
id: toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: "${{ env.MSRV }}"
|
||||
targets: "wasm32-unknown-unknown"
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/bin
|
||||
@@ -101,10 +105,6 @@ jobs:
|
||||
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
lldap-ui-
|
||||
- name: Install rollup (nodejs)
|
||||
run: npm install -g rollup
|
||||
- name: Add wasm target (rust)
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
- name: Install wasm-pack with cargo
|
||||
run: cargo install wasm-pack || true
|
||||
env:
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
- name: Check build path
|
||||
run: ls -al app/
|
||||
- name: Upload ui artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ui
|
||||
path: app/
|
||||
@@ -125,21 +125,25 @@ jobs:
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [armv7-unknown-linux-gnueabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
||||
target: [armv7-unknown-linux-musleabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
|
||||
container:
|
||||
image: nitnelave/rust-dev:latest
|
||||
image: lldap/rust-dev:latest
|
||||
env:
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-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
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=+crt-static
|
||||
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
- uses: actions/cache@v3
|
||||
uses: actions/checkout@v6.0.2
|
||||
- name: Install Rust
|
||||
id: toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: "${{ env.MSRV }}"
|
||||
targets: "${{ matrix.target }}"
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
.cargo/bin
|
||||
@@ -151,21 +155,21 @@ jobs:
|
||||
restore-keys: |
|
||||
lldap-bin-${{ matrix.target }}-
|
||||
- name: Compile ${{ matrix.target }} lldap and tools
|
||||
run: cargo build --target=${{ matrix.target }} --release -p lldap -p migration-tool -p lldap_set_password
|
||||
run: cargo build --target=${{ matrix.target }} --release -p lldap -p lldap_migration_tool -p lldap_set_password
|
||||
- name: Check path
|
||||
run: ls -al target/release
|
||||
- name: Upload ${{ matrix.target}} lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.target}}-lldap-bin
|
||||
path: target/${{ matrix.target }}/release/lldap
|
||||
- name: Upload ${{ matrix.target }} migration tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.target }}-migration-tool-bin
|
||||
path: target/${{ matrix.target }}/release/migration-tool
|
||||
name: ${{ matrix.target }}-lldap_migration_tool-bin
|
||||
path: target/${{ matrix.target }}/release/lldap_migration_tool
|
||||
- name: Upload ${{ matrix.target }} password tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.target }}-lldap_set_password-bin
|
||||
path: target/${{ matrix.target }}/release/lldap_set_password
|
||||
@@ -186,7 +190,7 @@ jobs:
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
||||
options: >-
|
||||
--name mariadb
|
||||
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
--health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
postgresql:
|
||||
image: postgres:latest
|
||||
@@ -205,7 +209,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap-bin
|
||||
path: bin/
|
||||
@@ -222,6 +226,8 @@ jobs:
|
||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
|
||||
LLDAP_ldap_port: 3890
|
||||
LLDAP_http_port: 17170
|
||||
LLDAP_JWT_SECRET: verysecret
|
||||
LLDAP_LDAP_USER_PASS: password
|
||||
|
||||
|
||||
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
|
||||
@@ -233,6 +239,8 @@ jobs:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
|
||||
LLDAP_ldap_port: 3891
|
||||
LLDAP_http_port: 17171
|
||||
LLDAP_JWT_SECRET: verysecret
|
||||
LLDAP_LDAP_USER_PASS: password
|
||||
|
||||
|
||||
- name: Run lldap with sqlite DB and healthcheck
|
||||
@@ -244,6 +252,8 @@ jobs:
|
||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||
LLDAP_ldap_port: 3892
|
||||
LLDAP_http_port: 17172
|
||||
LLDAP_JWT_SECRET: verysecret
|
||||
LLDAP_LDAP_USER_PASS: password
|
||||
|
||||
- name: Check DB container logs
|
||||
run: |
|
||||
@@ -281,7 +291,7 @@ jobs:
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
||||
options: >-
|
||||
--name mariadb
|
||||
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
--health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
|
||||
mysql:
|
||||
@@ -299,14 +309,19 @@ jobs:
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
sparse-checkout: 'scripts'
|
||||
|
||||
- name: Download LLDAP artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap-bin
|
||||
path: bin/
|
||||
|
||||
- name: Download LLDAP set password
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl-lldap_set_password-bin
|
||||
path: bin/
|
||||
@@ -325,9 +340,9 @@ jobs:
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: sqlite://users.db?mode=rwc
|
||||
LLDAP_ldap_port: 3890
|
||||
LLDAP_http_port: 17170
|
||||
LLDAP_DATABASE_URL: sqlite://users.db?mode=rwc
|
||||
LLDAP_LDAP_PORT: 3890
|
||||
LLDAP_HTTP_PORT: 17170
|
||||
LLDAP_LDAP_USER_PASS: ldappass
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
@@ -347,58 +362,61 @@ jobs:
|
||||
|
||||
- name: Export and Converting to Postgress
|
||||
run: |
|
||||
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
|
||||
chmod +x ./helper.sh
|
||||
./helper.sh | sqlite3 ./users.db > ./dump.sql
|
||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e ":a; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),1([^']*\);)$/\1,true\3/; s/(INSERT INTO (user_attribute_schema|jwt_storage)\(.*\) VALUES\(.*),0([^']*\);)$/\1,false\3/; ta" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
|
||||
- name: Create schema on postgres
|
||||
env:
|
||||
LLDAP_DATABASE_URL: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
run: |
|
||||
bin/lldap create_schema -d postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
bin/lldap create_schema
|
||||
|
||||
- name: Copy converted db to postgress and import
|
||||
run: |
|
||||
docker ps -a
|
||||
docker cp ./dump.sql postgresql:/tmp/dump.sql
|
||||
docker exec postgresql bash -c "psql -U lldapuser -d lldap < /tmp/dump.sql"
|
||||
docker exec postgresql bash -c "psql -U lldapuser -d lldap < /tmp/dump.sql" | tee import.log
|
||||
rm ./dump.sql
|
||||
! grep ERROR import.log > /dev/null
|
||||
|
||||
- name: Export and Converting to mariadb
|
||||
run: |
|
||||
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
|
||||
chmod +x ./helper.sh
|
||||
./helper.sh | sqlite3 ./users.db > ./dump.sql
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
cp ./dump.sql ./dump-no-sed.sql
|
||||
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||
|
||||
- name: Create schema on mariadb
|
||||
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
env:
|
||||
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
run: bin/lldap create_schema
|
||||
|
||||
- name: Copy converted db to mariadb and import
|
||||
run: |
|
||||
docker ps -a
|
||||
docker cp ./dump.sql mariadb:/tmp/dump.sql
|
||||
docker exec mariadb bash -c "mariadb -ulldapuser -plldappass -f lldap < /tmp/dump.sql"
|
||||
docker exec mariadb bash -c "mariadb -ulldapuser -plldappass -f lldap < /tmp/dump.sql" | tee import.log
|
||||
rm ./dump.sql
|
||||
! grep ERROR import.log > /dev/null
|
||||
|
||||
- name: Export and Converting to mysql
|
||||
run: |
|
||||
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
|
||||
chmod +x ./helper.sh
|
||||
./helper.sh | sqlite3 ./users.db > ./dump.sql
|
||||
bash ./scripts/sqlite_dump_commands.sh | sqlite3 ./users.db > ./dump.sql
|
||||
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
|
||||
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
|
||||
|
||||
- name: Create schema on mysql
|
||||
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
env:
|
||||
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
run: bin/lldap create_schema
|
||||
|
||||
- name: Copy converted db to mysql and import
|
||||
run: |
|
||||
docker ps -a
|
||||
docker cp ./dump.sql mysql:/tmp/dump.sql
|
||||
docker exec mysql bash -c "mysql -ulldapuser -plldappass -f lldap < /tmp/dump.sql"
|
||||
docker exec mysql bash -c "mysql -ulldapuser -plldappass -f lldap < /tmp/dump.sql" | tee import.log
|
||||
rm ./dump.sql
|
||||
! grep ERROR import.log > /dev/null
|
||||
|
||||
- name: Run lldap with postgres DB and healthcheck again
|
||||
run: |
|
||||
@@ -406,10 +424,9 @@ jobs:
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
LLDAP_ldap_port: 3891
|
||||
LLDAP_http_port: 17171
|
||||
LLDAP_LDAP_USER_PASS: ldappass
|
||||
LLDAP_DATABASE_URL: postgres://lldapuser:lldappass@localhost:5432/lldap
|
||||
LLDAP_LDAP_PORT: 3891
|
||||
LLDAP_HTTP_PORT: 17171
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Run lldap with mariaDB and healthcheck again
|
||||
@@ -418,9 +435,9 @@ jobs:
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
LLDAP_ldap_port: 3892
|
||||
LLDAP_http_port: 17172
|
||||
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3306/lldap
|
||||
LLDAP_LDAP_PORT: 3892
|
||||
LLDAP_HTTP_PORT: 17172
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Run lldap with mysql and healthcheck again
|
||||
@@ -429,17 +446,21 @@ jobs:
|
||||
sleep 10s
|
||||
bin/lldap healthcheck
|
||||
env:
|
||||
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
LLDAP_ldap_port: 3893
|
||||
LLDAP_http_port: 17173
|
||||
LLDAP_DATABASE_URL: mysql://lldapuser:lldappass@localhost:3307/lldap
|
||||
LLDAP_LDAP_PORT: 3893
|
||||
LLDAP_HTTP_PORT: 17173
|
||||
LLDAP_JWT_SECRET: somejwtsecret
|
||||
|
||||
- name: Test Dummy User
|
||||
run: |
|
||||
ldapsearch -H ldap://localhost:3891 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
ldapsearch -H ldap://localhost:3892 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
ldapsearch -H ldap://localhost:3893 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
- name: Test Dummy User Postgres
|
||||
run: ldapsearch -H ldap://localhost:3891 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
- name: Test Dummy User MariaDB
|
||||
run: ldapsearch -H ldap://localhost:3892 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
- name: Test Dummy User MySQL
|
||||
run: ldapsearch -H ldap://localhost:3893 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
|
||||
|
||||
########################################
|
||||
#### BUILD BASE IMAGE ##################
|
||||
########################################
|
||||
build-docker-image:
|
||||
needs: [build-ui, build-bin]
|
||||
name: Build Docker image
|
||||
@@ -449,7 +470,7 @@ jobs:
|
||||
container: ["debian","alpine"]
|
||||
include:
|
||||
- container: alpine
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
type=ref,event=pr
|
||||
type=semver,pattern=v{{version}}
|
||||
@@ -462,6 +483,8 @@ jobs:
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }},suffix=
|
||||
type=raw,value=latest,enable={{ is_default_branch }},suffix=
|
||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }}
|
||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }},suffix=
|
||||
- container: debian
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
@@ -471,31 +494,69 @@ jobs:
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{ is_default_branch }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
type=raw,value={{ date 'YYYY-MM-DD' }},enable={{ is_default_branch }}
|
||||
services:
|
||||
registry:
|
||||
image: registry:2
|
||||
ports:
|
||||
- 5000:5000
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: bin
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Setup buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Docker ${{ matrix.container }} meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
- name: Docker ${{ matrix.container }} Base meta
|
||||
id: meta-base
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
localhost:5000/lldap/lldap
|
||||
tags: ${{ matrix.container }}-base
|
||||
|
||||
- name: Build ${{ matrix.container }} Base Docker Image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
# On PR will fail, force fully uncomment push: true, or docker image will fail for next steps
|
||||
#push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}-base
|
||||
tags: |
|
||||
${{ steps.meta-base.outputs.tags }}
|
||||
labels: ${{ steps.meta-base.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
#####################################
|
||||
#### build variants docker image ####
|
||||
#####################################
|
||||
|
||||
- name: Docker ${{ matrix.container }}-rootless meta
|
||||
id: meta-rootless
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
@@ -510,12 +571,48 @@ jobs:
|
||||
# latest-alpine
|
||||
# stable
|
||||
# stable-alpine
|
||||
# YYYY-MM-DD
|
||||
# YYYY-MM-DD-alpine
|
||||
#################
|
||||
# vX-debian
|
||||
# vX.Y-debian
|
||||
# vX.Y.Z-debian
|
||||
# latest-debian
|
||||
# stable-debian
|
||||
# YYYY-MM-DD-debian
|
||||
#################
|
||||
# Check matrix for tag list definition
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-${{ matrix.container }}-rootless
|
||||
tags: ${{ matrix.tags }}
|
||||
|
||||
- name: Docker ${{ matrix.container }} meta
|
||||
id: meta-standard
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
lldap/lldap
|
||||
ghcr.io/lldap/lldap
|
||||
# Wanted Docker tags
|
||||
# vX-alpine
|
||||
# vX.Y-alpine
|
||||
# vX.Y.Z-alpine
|
||||
# latest
|
||||
# latest-alpine
|
||||
# stable
|
||||
# stable-alpine
|
||||
# YYYY-MM-DD
|
||||
# YYYY-MM-DD-alpine
|
||||
#################
|
||||
# vX-debian
|
||||
# vX.Y-debian
|
||||
# vX.Y.Z-debian
|
||||
# latest-debian
|
||||
# stable-debian
|
||||
# YYYY-MM-DD-debian
|
||||
#################
|
||||
# Check matrix for tag list definition
|
||||
flavor: |
|
||||
@@ -526,39 +623,49 @@ jobs:
|
||||
# Docker login to nitnelave/lldap and lldap/lldap
|
||||
- name: Login to Nitnelave/LLDAP Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: nitnelave
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build ${{ matrix.container }}-rootless Docker Image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}-rootless
|
||||
tags: |
|
||||
${{ steps.meta-rootless.outputs.tags }}
|
||||
labels: ${{ steps.meta-rootless.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
########################################
|
||||
#### docker image build ####
|
||||
########################################
|
||||
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
|
||||
- name: Build ${{ matrix.container }} Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
${{ steps.meta-standard.outputs.tags }}
|
||||
labels: ${{ steps.meta-standard.outputs.labels }}
|
||||
cache-from: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -566,7 +673,7 @@ jobs:
|
||||
|
||||
- name: Update lldap repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -584,7 +691,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: bin/
|
||||
- name: Check file
|
||||
@@ -593,19 +700,19 @@ jobs:
|
||||
run: |
|
||||
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap bin/aarch64-lldap
|
||||
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap bin/amd64-lldap
|
||||
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap bin/armhf-lldap
|
||||
mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool bin/aarch64-migration-tool
|
||||
mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool bin/amd64-migration-tool
|
||||
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool bin/armhf-migration-tool
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap bin/armhf-lldap
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/aarch64-lldap_migration_tool
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool bin/amd64-lldap_migration_tool
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool bin/armhf-lldap_migration_tool
|
||||
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/aarch64-lldap_set_password
|
||||
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/amd64-lldap_set_password
|
||||
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
|
||||
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
|
||||
chmod +x bin/*-lldap
|
||||
chmod +x bin/*-migration-tool
|
||||
chmod +x bin/*-lldap_migration_tool
|
||||
chmod +x bin/*-lldap_set_password
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
@@ -627,9 +734,9 @@ jobs:
|
||||
mv bin/aarch64-lldap aarch64-lldap/lldap
|
||||
mv bin/amd64-lldap amd64-lldap/lldap
|
||||
mv bin/armhf-lldap armhf-lldap/lldap
|
||||
mv bin/aarch64-migration-tool aarch64-lldap/migration-tool
|
||||
mv bin/amd64-migration-tool amd64-lldap/migration-tool
|
||||
mv bin/armhf-migration-tool armhf-lldap/migration-tool
|
||||
mv bin/aarch64-lldap_migration_tool aarch64-lldap/lldap_migration_tool
|
||||
mv bin/amd64-lldap_migration_tool amd64-lldap/lldap_migration_tool
|
||||
mv bin/armhf-lldap_migration_tool armhf-lldap/lldap_migration_tool
|
||||
mv bin/aarch64-lldap_set_password aarch64-lldap/lldap_set_password
|
||||
mv bin/amd64-lldap_set_password amd64-lldap/lldap_set_password
|
||||
mv bin/armhf-lldap_set_password armhf-lldap/lldap_set_password
|
||||
@@ -655,5 +762,9 @@ jobs:
|
||||
artifacts: aarch64-lldap.tar.gz,
|
||||
amd64-lldap.tar.gz,
|
||||
armhf-lldap.tar.gz
|
||||
draft: true
|
||||
omitBodyDuringUpdate: true
|
||||
omitDraftDuringUpdate: true
|
||||
omitNameDuringUpdate: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
+32
-30
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
MSRV: "1.89.0"
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
@@ -33,14 +34,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.5.0
|
||||
uses: actions/checkout@v6.0.2
|
||||
- name: Install Rust
|
||||
id: toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: "${{ env.MSRV }}"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
run: cargo +${{steps.toolchain.outputs.name}} build --verbose --workspace
|
||||
- name: Run tests
|
||||
run: cargo test --verbose --workspace
|
||||
run: cargo +${{steps.toolchain.outputs.name}} test --verbose --workspace
|
||||
- name: Generate GraphQL schema
|
||||
run: cargo run -- export_graphql_schema -o generated_schema.graphql
|
||||
run: cargo +${{steps.toolchain.outputs.name}} run -- export_graphql_schema -o generated_schema.graphql
|
||||
- name: Check schema
|
||||
run: diff schema.graphql generated_schema.graphql || (echo "The schema file is out of date. Please run `./export_schema.sh`" && false)
|
||||
|
||||
@@ -52,15 +58,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.5.0
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
uses: actions/checkout@v6.0.2
|
||||
- name: Install Rust
|
||||
id: toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
command: clippy
|
||||
args: --tests --all -- -D warnings
|
||||
toolchain: "${{ env.MSRV }}"
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo +${{steps.toolchain.outputs.name}} clippy --tests --workspace -- -D warnings
|
||||
|
||||
format:
|
||||
name: cargo fmt
|
||||
@@ -69,24 +75,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3.5.0
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run cargo fmt
|
||||
uses: actions-rs/cargo@v1
|
||||
uses: actions/checkout@v6.0.2
|
||||
- name: Install Rust
|
||||
id: toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
toolchain: "${{ env.MSRV }}"
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo +${{steps.toolchain.outputs.name}} fmt --check --all
|
||||
|
||||
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.5.0
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||
@@ -99,16 +107,10 @@ jobs:
|
||||
run: cargo llvm-cov --workspace --no-report
|
||||
- name: Aggregate reports
|
||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
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'
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
codecov_yml_path: .github/codecov.yml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -29,3 +29,8 @@ recipe.json
|
||||
lldap_config.toml
|
||||
cert.pem
|
||||
key.pem
|
||||
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
.direnv
|
||||
|
||||
+220
-1
@@ -5,10 +5,229 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.2] 2025-07-21
|
||||
|
||||
Small release, focused on LDAP improvements and ongoing maintenance.
|
||||
|
||||
### Added
|
||||
|
||||
- LDAP
|
||||
- Support for searching groups by their `groupid`
|
||||
- Support for `whoamiOID`
|
||||
- Support for creating groups
|
||||
- Support for subschema entry
|
||||
- Custom assets path.
|
||||
- New endpoint for requesting client settings
|
||||
|
||||
### Changed
|
||||
|
||||
- A missing JWT secret now prevents startup.
|
||||
- Attributes with invalid characters (such as underscores) cannot be created anymore.
|
||||
- Searching custom (string) attributes is now case insensitive.
|
||||
- Using the top-level `firstName`, `lastName` and `avatar` GraphQL fields for users is now deprecated. Use the `attributes` field instead.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `lldap_set_password` now uses the system's SSL certificates.
|
||||
|
||||
### Cleanups
|
||||
|
||||
- Split the main `lldap` crate into many sub-crates
|
||||
- Various dependency version bumps
|
||||
- Upgraded to 2024 Rust edition
|
||||
- Docs/FAQ improvements
|
||||
|
||||
### Bootstrap script
|
||||
|
||||
- Custom attributes support
|
||||
- Read the paswsord from a file
|
||||
- Resilient to no user or group files
|
||||
|
||||
### New services
|
||||
|
||||
- Discord integration (Discord role to LLDAP user)
|
||||
- HashiCorp
|
||||
- Jellyfin 2FA with Duo
|
||||
- Kimai
|
||||
- Mailcow
|
||||
- Peertube
|
||||
- Penpot
|
||||
- PgAdmin
|
||||
- Project Quay
|
||||
- Quadlet
|
||||
- Snipe-IT
|
||||
- SSSD
|
||||
- Stalwart
|
||||
- UnifiOS
|
||||
|
||||
## [0.6.1] 2024-11-22
|
||||
|
||||
Small release, mainly to fix a migration issue with Sqlite and Postgresql.
|
||||
|
||||
### Added
|
||||
|
||||
- Added a link to a community terraform provider (#1035)
|
||||
|
||||
### Changed
|
||||
|
||||
- The opaque dependency now points to the official crate rather than a fork (#1040)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Migration of the DB schema from 7 to 8 is now automatic for sqlite, and fixed for postgres (#1045)
|
||||
- The startup warning about `key_seed` applying instead of `key_file` now has instructions on how to silence it (#1032)
|
||||
|
||||
### New services
|
||||
|
||||
- OneDev
|
||||
|
||||
## [0.6.0] 2024-11-09
|
||||
|
||||
### Breaking
|
||||
|
||||
- The endpoint `/auth/reset/step1` is now `POST` instead of `GET` (#704)
|
||||
|
||||
### Added
|
||||
|
||||
- Custom attributes are now supported (#67) ! You can add new fields (string, integers, JPEG or dates) to users and query them. That unlocks many integrations with other services, and allows for a deeper/more customized integration. Special thanks to @pixelrazor and @bojidar-bg for their help with the UI.
|
||||
- Custom object classes (for all users/groups) can now be added (#833)
|
||||
- Barebones support for Paged Results Control (no paging, no respect for windows, but a correct response with all the results) (#698)
|
||||
- A daily docker image is tagged and released. (#613)
|
||||
- A bootstrap script allows reading the list of users/groups from a file and making sure the server contains exactly the same thing. (#654)
|
||||
- Make it possible to serve lldap behind a sub-path in (#752)
|
||||
- LLDAP can now be found on a custom package repository for opensuse, fedora, ubuntu, debian and centos ([Repository link](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap)). Thanks @Masgalor for setting it up and maintaining it.
|
||||
- There's now an option to force reset the admin password (#748) optionally on every restart (#959)
|
||||
- There's a rootless docker container (#755)
|
||||
- entryDN is now supported (#780)
|
||||
- Unknown LDAP controls are now detected and ignored (#787, #799)
|
||||
- A community-developed CLI for scripting (#793)
|
||||
- Added a way to print raw logs to debug long-running sessions (#992)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- The official docker repository is now `lldap/lldap`
|
||||
- Removed password length limitation in lldap_set_password tool
|
||||
- Group names and emails are now case insensitive, but keep their casing (#666)
|
||||
- Better error messages (and exit code (#745)) when changing the private key (#778, #1008), using the wrong SMTP port (#970), using the wrong env variables (#972)
|
||||
- Allow `member=` filters with plain user names (not full DNs) (#949)
|
||||
- Correctly detect and refuse anonymous binds (#974)
|
||||
- Clearer logging (#971, #981, #982)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Logging out applies globally, not just in the local browser. (#721)
|
||||
- It's no longer possible to create the same user twice (#745)
|
||||
- Fix wide substring filters (#738)
|
||||
- Don't log the database password if provided in the connection URL (#735)
|
||||
- Fix a panic when postgres uses a different collation (#821)
|
||||
- The UI now defaults to the user ID for users with no display names (#843)
|
||||
- Fix searching for users with more than one `memberOf` filter (#872)
|
||||
- Fix compilation on Windows (#932) and Illumos (#964)
|
||||
- The UI now correctly detects whether password resets are enabled. (#753)
|
||||
- Fix a missing lowercasing of username when changing passwords through LDAP (#1012)
|
||||
- Fix SQLite writers erroring when racing (#1021)
|
||||
- LDAP sessions no longer buffer their logs until unbind, causing memory leaks (#1025)
|
||||
|
||||
### Performance
|
||||
|
||||
- Only expand attributes once per query, not per result (#687)
|
||||
|
||||
### Security
|
||||
|
||||
- When asked to send a password reset to an unknown email, sleep for 3 seconds and don't print the email in the error (#887)
|
||||
|
||||
### New services
|
||||
|
||||
Linux user accounts can now be managed by LLDAP, using PAM and nslcd.
|
||||
|
||||
- Apereo CAS server
|
||||
- Carpal
|
||||
- Gitlab
|
||||
- Grocy
|
||||
- Harbor
|
||||
- Home Assistant
|
||||
- Jenkins
|
||||
- Kasm
|
||||
- Maddy
|
||||
- Mastodon
|
||||
- Metabase
|
||||
- MegaRAC-BMC
|
||||
- Netbox
|
||||
- OCIS
|
||||
- Prosody
|
||||
- Radicale
|
||||
- SonarQube
|
||||
- Traccar
|
||||
- Zitadel
|
||||
|
||||
## [0.5.0] 2023-09-14
|
||||
|
||||
### Breaking
|
||||
|
||||
- Emails and UUIDs are now enforced to be unique.
|
||||
- If you have several users with the same email, you'll have to disambiguate
|
||||
them. You can do that by either issuing SQL commands directly
|
||||
(`UPDATE users SET email = 'x@x' WHERE user_id = 'bob';`), or by reverting
|
||||
to a 0.4.x version of LLDAP and editing the user through the web UI.
|
||||
An error will prevent LLDAP 0.5+ from starting otherwise.
|
||||
- This was done to prevent account takeover for systems that allow to
|
||||
login via email.
|
||||
|
||||
### Added
|
||||
|
||||
- The server private key can be set as a seed from an env variable (#504).
|
||||
- This is especially useful when you have multiple containers, they don't
|
||||
need to share a writeable folder.
|
||||
- Added support for changing the password through a plain LDAP Modify
|
||||
operation (as opposed to an extended operation), to allow Jellyfin
|
||||
to change password (#620).
|
||||
- Allow creating a user with multiple objectClass (#612).
|
||||
- Emails now have a message ID (#608).
|
||||
- Added a warning for browsers that have WASM/JS disabled (#639).
|
||||
- Added support for querying OUs in LDAP (#669).
|
||||
- Added a button to clear the avatar in the UI (#358).
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Groups are now sorted by name in the web UI (#623).
|
||||
- ARM build now uses musl (#584).
|
||||
- Improved logging.
|
||||
- Default admin user is only created if there are no admins (#563).
|
||||
- That allows you to remove the default admin, making it harder to
|
||||
bruteforce.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed URL parsing with a trailing slash in the password setting utility
|
||||
(#597).
|
||||
|
||||
In addition to all that, there was significant progress towards #67,
|
||||
user-defined attributes. That complex feature will unblock integration with many
|
||||
systems, including PAM authentication.
|
||||
|
||||
### New services
|
||||
|
||||
- Ejabberd
|
||||
- Ergo
|
||||
- LibreNMS
|
||||
- Mealie
|
||||
- MinIO
|
||||
- OpnSense
|
||||
- PfSense
|
||||
- PowerDnsAdmin
|
||||
- Proxmox
|
||||
- Squid
|
||||
- Tandoor recipes
|
||||
- TheLounge
|
||||
- Zabbix-web
|
||||
- Zulip
|
||||
|
||||
## [0.4.3] 2023-04-11
|
||||
|
||||
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
|
||||
and on DockerHub (although we will keep publishing the images to
|
||||
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`.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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).
|
||||
|
||||
For an alternative development environment setup, see [docs/nix-development.md](docs/nix-development.md).
|
||||
|
||||
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.
|
||||
Generated
+3056
-1403
File diff suppressed because it is too large
Load Diff
+20
-11
@@ -1,13 +1,22 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"server",
|
||||
"auth",
|
||||
"app",
|
||||
"migration-tool",
|
||||
"set-password",
|
||||
"server",
|
||||
"app",
|
||||
"migration-tool",
|
||||
"set-password",
|
||||
"crates/*",
|
||||
]
|
||||
|
||||
default-members = ["server"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
documentation = "https://github.com/lldap/lldap"
|
||||
edition = "2024"
|
||||
homepage = "https://github.com/lldap/lldap"
|
||||
license = "GPL-3.0-only"
|
||||
repository = "https://github.com/lldap/lldap"
|
||||
rust-version = "1.89.0"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
@@ -15,9 +24,9 @@ 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'
|
||||
[workspace.dependencies.sea-orm]
|
||||
version = "1.1.8"
|
||||
default-features = false
|
||||
|
||||
[patch.crates-io.lber]
|
||||
git = 'https://github.com/inejge/ldap3/'
|
||||
[workspace.dependencies.serde]
|
||||
version = "1"
|
||||
|
||||
+7
-6
@@ -1,5 +1,5 @@
|
||||
# Build image
|
||||
FROM rust:alpine3.16 AS chef
|
||||
FROM rust:alpine3.21 AS chef
|
||||
|
||||
RUN set -x \
|
||||
# Add user
|
||||
@@ -31,19 +31,19 @@ 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 -p lldap_set_password \
|
||||
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.16
|
||||
FROM alpine:3.21
|
||||
|
||||
ENV GOSU_VERSION 1.14
|
||||
ENV GOSU_VERSION=1.14
|
||||
# Fetch gosu from git
|
||||
RUN set -eux; \
|
||||
\
|
||||
@@ -78,8 +78,9 @@ 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 /app/target/release/lldap_set_password ./
|
||||
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
|
||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||
COPY scripts/bootstrap.sh ./
|
||||
|
||||
RUN set -x \
|
||||
&& apk add --no-cache bash tzdata \
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
build-dev-container:
|
||||
docker buildx build --tag lldap/rust-dev --file .github/workflows/Dockerfile.dev --push .github/workflows
|
||||
|
||||
prepare-release:
|
||||
./prepare-release.sh
|
||||
@@ -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"
|
||||
@@ -26,21 +27,21 @@
|
||||
<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)
|
||||
- [Installation](docs/install.md)
|
||||
- [Usage](#usage)
|
||||
- [Recommended architecture](#recommended-architecture)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [Known compatible services](#known-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)
|
||||
- [Incompatible services](#incompatible-services)
|
||||
- [Frequently Asked Questions](#frequently-asked-questions)
|
||||
- [Contributions](#contributions)
|
||||
|
||||
## About
|
||||
@@ -51,7 +52,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"
|
||||
@@ -82,137 +83,60 @@ MySQL/MariaDB or PostgreSQL.
|
||||
|
||||
## Installation
|
||||
|
||||
### With Docker
|
||||
It's possible to install lldap from OCI images ([docker](docs/install.md#with-docker)/[podman](docs/install.md#with-podman)), from [Kubernetes](docs/install.md#with-kubernetes), [TrueNAS](docs/install.md#truenas-scale), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD).
|
||||
|
||||
The image is available at `nitnelave/lldap`. You should persist the `/data`
|
||||
folder, which contains your configuration, the database and the private key
|
||||
file.
|
||||
Building [from source](docs/install.md#from-source) and [cross-compiling](docs/install.md#cross-compilation) to a different hardware architecture is also supported.
|
||||
|
||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||
`jwt_secret` and `ldap_user_pass`, unless you override them with env variables).
|
||||
Environment variables should be prefixed with `LLDAP_` to override the
|
||||
configuration.
|
||||
## Usage
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
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
|
||||
contents are loaded into the respective configuration parameters. Note that
|
||||
`_FILE` variables take precedence.
|
||||
You can create and manage custom attributes through the Web UI, or through the
|
||||
community-contributed CLI frontend (
|
||||
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli)). This is necessary
|
||||
for some service integrations.
|
||||
|
||||
Example for docker compose:
|
||||
The [bootstrap.sh](scripts/bootstrap.sh) script can enforce a list of
|
||||
users/groups/attributes from a given file, reflecting it on the server.
|
||||
|
||||
- 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.
|
||||
To manage the user, group and membership lifecycle in an infrastructure-as-code
|
||||
scenario you can use the unofficial [LLDAP terraform provider in the terraform registry](https://registry.terraform.io/providers/tasansga/lldap/latest).
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
LLDAP is also very scriptable, through its GraphQL API. See the
|
||||
[Scripting](docs/scripting.md) docs for more info.
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
driver: local
|
||||
### Recommended architecture
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/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:
|
||||
- UID=####
|
||||
- GID=####
|
||||
- TZ=####/####
|
||||
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
|
||||
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
|
||||
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
|
||||
# You can also set a different database:
|
||||
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||
```
|
||||
If you are using containers, a sample architecture could look like this:
|
||||
|
||||
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
|
||||
|
||||
### From source
|
||||
|
||||
#### Backend
|
||||
|
||||
To compile the project, you'll need:
|
||||
|
||||
- curl and gzip: `sudo apt install curl gzip`
|
||||
- Rust/Cargo: [rustup.rs](https://rustup.rs/)
|
||||
|
||||
Then you can compile the server (and the migration tool if you want):
|
||||
|
||||
```shell
|
||||
cargo build --release -p lldap -p 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:
|
||||
|
||||
- WASM-pack: `cargo install wasm-pack`
|
||||
|
||||
Then you can build the frontend files with
|
||||
|
||||
```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
|
||||
arguments to `cargo run`. Have a look at the docker template:
|
||||
`lldap_config.docker_template.toml`.
|
||||
|
||||
You can also install it as a systemd service, see
|
||||
[lldap.service](example_configs/lldap.service).
|
||||
|
||||
### Cross-compilation
|
||||
|
||||
Docker images are provided for AMD64, ARM64 and ARM/V7.
|
||||
|
||||
If you want to cross-compile yourself, you can do so by installing
|
||||
[`cross`](https://github.com/rust-embedded/cross):
|
||||
|
||||
```sh
|
||||
cargo install cross
|
||||
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
|
||||
./app/build.sh
|
||||
```
|
||||
|
||||
(Replace `armv7-unknown-linux-musleabihf` with the correct Rust target for your
|
||||
device.)
|
||||
|
||||
You can then get the compiled server binary in
|
||||
`target/armv7-unknown-linux-musleabihf/release/lldap` and the various needed files
|
||||
(`index.html`, `main.js`, `pkg` folder) in the `app` folder. Copy them to the
|
||||
Raspberry Pi (or other target), with the folder structure maintained (`app`
|
||||
files in an `app` folder next to the binary).
|
||||
- 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
|
||||
### Known compatible services
|
||||
|
||||
Most services that can use LDAP as an authentication provider should work out
|
||||
of the box. For new services, it's possible that they require a bit of tweaking
|
||||
@@ -220,6 +144,13 @@ on LLDAP's side to make things work. In that case, just create an issue with
|
||||
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
|
||||
the config).
|
||||
|
||||
Some specific clients have been tested to work and come with sample
|
||||
configuration files, or guides. See the [`example_configs`](example_configs/README.md)
|
||||
folder for example configs for integration with specific services.
|
||||
|
||||
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
|
||||
configuration guide](example_configs/pam/README.md). Integration with Windows (e.g. Samba) is WIP.
|
||||
|
||||
### General configuration guide
|
||||
|
||||
To configure the services that will talk to LLDAP, here are the values:
|
||||
@@ -239,117 +170,41 @@ filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
||||
admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
||||
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||
administration access to many services.
|
||||
administration access to many services. To prevent privilege escalation users in the
|
||||
`lldap_password_manager` group are not allowed to change passwords of admins in the
|
||||
`lldap_admin` group.
|
||||
|
||||
### Sample client configurations
|
||||
### Incompatible 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:
|
||||
Though we try to be maximally compatible, not every feature is supported; LLDAP
|
||||
is not a fully-featured LDAP server, intentionally so.
|
||||
|
||||
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.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)
|
||||
- [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)
|
||||
- [Nexus](example_configs/nexus.md)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [Rancher](example_configs/rancher.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Shaarli](example_configs/shaarli.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [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)
|
||||
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.
|
||||
|
||||
## Migrating from SQLite
|
||||
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).
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## Comparisons with other services
|
||||
In that category, the most prominent is Synology. It is, to date, the only
|
||||
service that seems definitely incompatible with LLDAP.
|
||||
|
||||
### vs OpenLDAP
|
||||
## Frequently Asked Questions
|
||||
|
||||
[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 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](http://www.freeipa.org) is the one-stop shop for identity management:
|
||||
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||
management, it also does security policies, single sign-on, certificate
|
||||
management, linux account management and so on.
|
||||
|
||||
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||
more complex to maintain, though.
|
||||
|
||||
LLDAP is much lighter to run (<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.
|
||||
- [I can't login](docs/faq.md#i-cant-log-in)
|
||||
- [Discord Integration](docs/faq.md#discord-integration)
|
||||
- [Migrating from SQLite](docs/faq.md#migrating-from-sqlite)
|
||||
- How does lldap compare [with OpenLDAP](docs/faq.md#how-does-lldap-compare-with-openldap)? [With FreeIPA](docs/faq.md#how-does-lldap-compare-with-freeipa)? [With Kanidm](docs/faq.md#how-does-lldap-compare-with-kanidm)?
|
||||
- [Does lldap support vhosts?](docs/faq.md#does-lldap-support-vhosts)
|
||||
- [Does lldap provide commercial support contracts?](docs/faq.md#does-lldap-provide-commercial-support-contracts)
|
||||
- [Can I make a donation to fund development?](docs/faq.md#can-i-make-a-donation-to-fund-development)
|
||||
- [Is lldap sustainable? Can we depend on it for our infrastructure?](docs/faq.md#is-lldap-sustainable-can-we-depend-on-it-for-our-infrastructure)
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
+46
-8
@@ -1,9 +1,14 @@
|
||||
[package]
|
||||
name = "lldap_app"
|
||||
version = "0.4.3"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
edition = "2021"
|
||||
version = "0.6.2"
|
||||
description = "Frontend for LLDAP"
|
||||
edition.workspace = true
|
||||
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
@@ -15,12 +20,11 @@ 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"
|
||||
validator = "0.14"
|
||||
validator_derive = "0.14"
|
||||
wasm-bindgen = "0.2.100"
|
||||
wasm-bindgen-futures = "*"
|
||||
yew = "0.19.3"
|
||||
yew-router = "0.16"
|
||||
@@ -33,12 +37,16 @@ version = "0.3"
|
||||
features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"Event",
|
||||
"FileReader",
|
||||
"FormData",
|
||||
"HtmlDocument",
|
||||
"HtmlFormElement",
|
||||
"HtmlInputElement",
|
||||
"HtmlOptionElement",
|
||||
"HtmlOptionsCollection",
|
||||
"HtmlSelectElement",
|
||||
"SubmitEvent",
|
||||
"console",
|
||||
]
|
||||
|
||||
@@ -48,15 +56,33 @@ features = [
|
||||
"wasmbind"
|
||||
]
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display", "from", "from_str"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
path = "../crates/auth"
|
||||
features = [ "opaque_client" ]
|
||||
|
||||
[dependencies.lldap_frontend_options]
|
||||
path = "../crates/frontend-options"
|
||||
|
||||
[dependencies.lldap_validation]
|
||||
path = "../crates/validation"
|
||||
|
||||
[dependencies.image]
|
||||
features = ["jpeg"]
|
||||
default-features = false
|
||||
version = "0.24"
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
[dependencies.strum]
|
||||
features = ["derive"]
|
||||
version = "0.25"
|
||||
|
||||
[dependencies.yew_form]
|
||||
git = "https://github.com/jfbilodeau/yew_form"
|
||||
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
@@ -67,3 +93,15 @@ rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = [
|
||||
'cfg(wasm_bindgen_unstable_test_coverage)',
|
||||
] }
|
||||
|
||||
[package.metadata.wasm-pack.profile.dev]
|
||||
wasm-opt = ['--enable-bulk-memory']
|
||||
[package.metadata.wasm-pack.profile.profiling]
|
||||
wasm-opt = ['--enable-bulk-memory']
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = ['--enable-bulk-memory']
|
||||
|
||||
+22
-4
@@ -4,7 +4,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/static/main.js" type="module" defer></script>
|
||||
<base href="/">
|
||||
<script src="static/main.js" type="module" defer></script>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
|
||||
rel="preload stylesheet"
|
||||
@@ -15,8 +16,8 @@
|
||||
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"
|
||||
<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
|
||||
@@ -33,7 +34,7 @@
|
||||
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;
|
||||
@@ -43,6 +44,23 @@
|
||||
</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>
|
||||
|
||||
+19
-2
@@ -13,8 +13,8 @@
|
||||
<script
|
||||
src="/static/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||
<script
|
||||
src="/static/darkmode.min.js"
|
||||
<script
|
||||
src="/static/darkmode.min.js"
|
||||
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
@@ -40,6 +40,23 @@
|
||||
</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,5 +1,5 @@
|
||||
mutation CreateGroup($name: String!) {
|
||||
createGroup(name: $name) {
|
||||
mutation CreateGroup($group: CreateGroupInput!) {
|
||||
createGroupWithDetails(request: $group) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation DeleteGroupAttributeQuery($name: String!) {
|
||||
deleteGroupAttribute(name: $name) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation DeleteUserAttributeQuery($name: String!) {
|
||||
deleteUserAttribute(name: $name) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
query GetGroupAttributesSchema {
|
||||
schema {
|
||||
groupSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,22 @@ query GetGroupDetails($id: Int!) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
attributes {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
schema {
|
||||
groupSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
query GetUserAttributesSchema {
|
||||
schema {
|
||||
userSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,30 @@ query GetUserDetails($id: String!) {
|
||||
user(userId: $id) {
|
||||
id
|
||||
email
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatar
|
||||
displayName
|
||||
creationDate
|
||||
uuid
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
attributes {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
schema {
|
||||
userSchema {
|
||||
attributes {
|
||||
name
|
||||
attributeType
|
||||
isList
|
||||
isVisible
|
||||
isEditable
|
||||
isHardcoded
|
||||
isReadonly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
mutation UpdateGroup($group: UpdateGroupInput!) {
|
||||
updateGroup(group: $group) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +155,13 @@ impl Component for AddGroupMemberComponent {
|
||||
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
|
||||
#[allow(unused_braces)]
|
||||
let make_select_option = |user: User| {
|
||||
let name = if user.display_name.is_empty() {
|
||||
user.id.clone()
|
||||
} else {
|
||||
user.display_name.clone()
|
||||
};
|
||||
html_nested! {
|
||||
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
|
||||
<SelectOption value={user.id.clone()} text={name} key={user.id} />
|
||||
}
|
||||
};
|
||||
html! {
|
||||
|
||||
+67
-143
@@ -1,54 +1,38 @@
|
||||
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, Redirect},
|
||||
user_details::UserDetails,
|
||||
user_schema_table::ListUserSchema,
|
||||
user_table::UserTable,
|
||||
},
|
||||
infra::{api::HostService, cookies::get_cookie},
|
||||
};
|
||||
|
||||
use gloo_console::error;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use lldap_frontend_options::Options;
|
||||
use yew::{
|
||||
function_component,
|
||||
Context, function_component,
|
||||
html::Scope,
|
||||
prelude::{html, Component, Html},
|
||||
Context,
|
||||
prelude::{Component, Html, html},
|
||||
};
|
||||
use yew_router::{
|
||||
BrowserRouter, Switch,
|
||||
prelude::{History, Location},
|
||||
scope_ext::RouterScopeExt,
|
||||
BrowserRouter, Switch,
|
||||
};
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = darkmode)]
|
||||
fn toggleDarkMode(doSave: bool);
|
||||
|
||||
#[wasm_bindgen]
|
||||
fn inDarkMode() -> bool;
|
||||
}
|
||||
|
||||
#[function_component(DarkModeToggle)]
|
||||
pub 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>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(AppContainer)]
|
||||
pub fn app_container() -> Html {
|
||||
html! {
|
||||
@@ -67,7 +51,7 @@ pub struct App {
|
||||
pub enum Msg {
|
||||
Login((String, bool)),
|
||||
Logout,
|
||||
PasswordResetProbeFinished(anyhow::Result<bool>),
|
||||
SettingsReceived(anyhow::Result<Options>),
|
||||
}
|
||||
|
||||
impl Component for App {
|
||||
@@ -92,9 +76,8 @@ impl Component for App {
|
||||
redirect_to: Self::get_redirect_route(ctx),
|
||||
password_reset_enabled: None,
|
||||
};
|
||||
ctx.link().send_future(async move {
|
||||
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
|
||||
});
|
||||
ctx.link()
|
||||
.send_future(async move { Msg::SettingsReceived(HostService::get_settings().await) });
|
||||
app.apply_initial_redirections(ctx);
|
||||
app
|
||||
}
|
||||
@@ -119,14 +102,11 @@ impl Component for App {
|
||||
self.redirect_to = None;
|
||||
history.push(AppRoute::Login);
|
||||
}
|
||||
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
|
||||
self.password_reset_enabled = Some(enabled);
|
||||
Msg::SettingsReceived(Ok(settings)) => {
|
||||
self.password_reset_enabled = Some(settings.password_reset_enabled);
|
||||
}
|
||||
Msg::PasswordResetProbeFinished(Err(err)) => {
|
||||
self.password_reset_enabled = Some(false);
|
||||
error!(&format!(
|
||||
"Could not probe for password reset support: {err:#}"
|
||||
));
|
||||
Msg::SettingsReceived(Err(err)) => {
|
||||
error!(err.to_string());
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -135,13 +115,14 @@ impl Component for App {
|
||||
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>
|
||||
{self.view_banner(ctx)}
|
||||
<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;">
|
||||
<main class="py-3" style="max-width: 1000px">
|
||||
<main class="py-3">
|
||||
<Switch<AppRoute>
|
||||
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
|
||||
/>
|
||||
@@ -215,29 +196,57 @@ impl App {
|
||||
AppRoute::CreateUser => html! {
|
||||
<CreateUserForm/>
|
||||
},
|
||||
AppRoute::Index | AppRoute::ListUsers => html! {
|
||||
<div>
|
||||
<UserTable />
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Create a user"}
|
||||
</Link>
|
||||
</div>
|
||||
},
|
||||
AppRoute::Index | AppRoute::ListUsers => {
|
||||
let user_button = |key| {
|
||||
html! {
|
||||
<Link classes="btn btn-primary" key={key} to={AppRoute::CreateUser}>
|
||||
<i class="bi-person-plus me-2"></i>
|
||||
{"Create a user"}
|
||||
</Link>
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div>
|
||||
{ user_button("top-create-user") }
|
||||
<UserTable />
|
||||
{ user_button("bottom-create-user") }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AppRoute::CreateGroup => html! {
|
||||
<CreateGroupForm/>
|
||||
},
|
||||
AppRoute::ListGroups => html! {
|
||||
<div>
|
||||
<GroupTable />
|
||||
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Create a group"}
|
||||
</Link>
|
||||
</div>
|
||||
AppRoute::CreateUserAttribute => html! {
|
||||
<CreateUserAttributeForm/>
|
||||
},
|
||||
AppRoute::CreateGroupAttribute => html! {
|
||||
<CreateGroupAttributeForm/>
|
||||
},
|
||||
AppRoute::ListGroups => {
|
||||
let group_button = |key| {
|
||||
html! {
|
||||
<Link classes="btn btn-primary" key={key} to={AppRoute::CreateGroup}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Create a group"}
|
||||
</Link>
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div>
|
||||
{ group_button("top-create-group") }
|
||||
<GroupTable />
|
||||
{ group_button("bottom-create-group") }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AppRoute::ListUserSchema => html! {
|
||||
<ListUserSchema />
|
||||
},
|
||||
AppRoute::ListGroupSchema => html! {
|
||||
<ListGroupSchema />
|
||||
},
|
||||
AppRoute::GroupDetails { group_id } => html! {
|
||||
<GroupDetails group_id={*group_id} />
|
||||
<GroupDetails group_id={*group_id} is_admin={is_admin} />
|
||||
},
|
||||
AppRoute::UserDetails { user_id } => html! {
|
||||
<UserDetails username={user_id.clone()} is_admin={is_admin} />
|
||||
@@ -263,91 +272,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_banner(&self, ctx: &Context<Self>) -> 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="/" 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 self.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>
|
||||
</>
|
||||
} } else { html!{} } }
|
||||
</ul>
|
||||
{ self.view_user_menu(ctx) }
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
fn view_user_menu(&self, ctx: &Context<Self>) -> Html {
|
||||
if let Some((user_id, _)) = &self.user_info {
|
||||
let link = ctx.link();
|
||||
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">
|
||||
<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>
|
||||
<span class="ms-2">
|
||||
{user_id}
|
||||
</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: user_id.clone() }}>
|
||||
{"View details"}
|
||||
</Link>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<LogoutButton on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn view_footer(&self) -> Html {
|
||||
html! {
|
||||
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||
@@ -355,7 +279,7 @@ impl App {
|
||||
<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">
|
||||
@@ -366,7 +290,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>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::infra::functional::{LoadableResult, use_graphql_call};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::{Properties, function_component, html, virtual_dom::AttrValue};
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_details.graphql",
|
||||
variables_derives = "Clone,PartialEq,Eq",
|
||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct GetUserDetails;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub user: AttrValue,
|
||||
#[prop_or(32)]
|
||||
pub width: i32,
|
||||
#[prop_or(32)]
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
#[function_component(Avatar)]
|
||||
pub fn avatar(props: &Props) -> Html {
|
||||
let user_details = use_graphql_call::<GetUserDetails>(get_user_details::Variables {
|
||||
id: props.user.to_string(),
|
||||
});
|
||||
|
||||
match &(*user_details) {
|
||||
LoadableResult::Loaded(Ok(response)) => {
|
||||
let avatar = response.user.avatar.clone();
|
||||
match &avatar {
|
||||
Some(data) => html! {
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", data)}
|
||||
style={format!("max-height:{}px;max-width:{}px;height:auto;width:auto;", props.height, props.width)}
|
||||
alt="Avatar" />
|
||||
},
|
||||
None => html! {
|
||||
<BlankAvatarDisplay
|
||||
width={props.width}
|
||||
height={props.height} />
|
||||
},
|
||||
}
|
||||
}
|
||||
LoadableResult::Loaded(Err(error)) => html! {
|
||||
<BlankAvatarDisplay
|
||||
error={error.to_string()}
|
||||
width={props.width}
|
||||
height={props.height} />
|
||||
},
|
||||
LoadableResult::Loading => html! {
|
||||
<BlankAvatarDisplay
|
||||
width={props.width}
|
||||
height={props.height} />
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct BlankAvatarDisplayProps {
|
||||
#[prop_or(None)]
|
||||
pub error: Option<AttrValue>,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
#[function_component(BlankAvatarDisplay)]
|
||||
fn blank_avatar_display(props: &BlankAvatarDisplayProps) -> Html {
|
||||
let fill = match &props.error {
|
||||
Some(_) => "red",
|
||||
None => "currentColor",
|
||||
};
|
||||
html! {
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width={props.width.to_string()}
|
||||
height={props.height.to_string()}
|
||||
fill={fill}
|
||||
class="bi bi-person-circle"
|
||||
viewBox="0 0 16 16">
|
||||
<title>{props.error.clone().unwrap_or(AttrValue::Static("Avatar"))}</title>
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use crate::components::{
|
||||
avatar::Avatar,
|
||||
logout::LogoutButton,
|
||||
router::{AppRoute, Link},
|
||||
};
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use yew::{Callback, Properties, function_component, html};
|
||||
|
||||
#[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,11 +1,14 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, Link},
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
@@ -97,7 +100,7 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
.context("Could not initialize login")?;
|
||||
self.opaque_data = OpaqueData::Login(login_start_request.state);
|
||||
let req = login::ClientLoginStartRequest {
|
||||
username: ctx.props().username.clone(),
|
||||
username: ctx.props().username.clone().into(),
|
||||
login_start_request: login_start_request.message,
|
||||
};
|
||||
self.common.call_backend(
|
||||
@@ -128,11 +131,13 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|
||||
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: ctx.props().username.clone(),
|
||||
username: ctx.props().username.clone().into(),
|
||||
registration_start_request: registration_start_request.message,
|
||||
};
|
||||
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
|
||||
@@ -205,7 +210,6 @@ impl Component for ChangePasswordForm {
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let is_admin = ctx.props().is_admin;
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<>
|
||||
<div class="mb-2 mt-2">
|
||||
@@ -222,90 +226,44 @@ impl Component for ChangePasswordForm {
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<form
|
||||
class="form">
|
||||
<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"
|
||||
input_type="password"
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
autocomplete="current-password"
|
||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("old_password")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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! {} }}
|
||||
<div class="form-group row mb-3">
|
||||
<label for="new_password"
|
||||
class="form-label col-sm-2 col-form-label">
|
||||
{"New Password"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</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={link.callback(|_| Msg::FormUpdate)} />
|
||||
<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-sm-2 col-form-label">
|
||||
{"Confirm Password"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</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={link.callback(|_| Msg::FormUpdate)} />
|
||||
<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"
|
||||
type="submit"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</Submit>
|
||||
</form>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
field::Field,
|
||||
submit::Submit,
|
||||
},
|
||||
router::AppRoute,
|
||||
},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{
|
||||
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
|
||||
read_all_form_attributes,
|
||||
},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{Result, ensure};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
@@ -10,6 +24,32 @@ 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/get_group_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql",
|
||||
extern_enums("AttributeType")
|
||||
)]
|
||||
pub struct GetGroupAttributesSchema;
|
||||
|
||||
use get_group_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute =
|
||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||
|
||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||
fn from(attr: &Attribute) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: false, // Need to be admin to edit it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
@@ -22,6 +62,8 @@ pub struct CreateGroup;
|
||||
pub struct CreateGroupForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateGroupModel>,
|
||||
attributes_schema: Option<Vec<Attribute>>,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
@@ -32,6 +74,7 @@ pub struct CreateGroupModel {
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
SubmitForm,
|
||||
CreateGroupResponse(Result<create_group::ResponseData>),
|
||||
}
|
||||
@@ -45,12 +88,33 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitForm => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
ensure!(self.form.validate(), "Check the form for errors");
|
||||
|
||||
let all_values = read_all_form_attributes(
|
||||
self.attributes_schema.iter().flatten(),
|
||||
&self.form_ref,
|
||||
IsAdmin(true),
|
||||
EmailIsRequired(false),
|
||||
)?;
|
||||
let attributes = Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| create_group::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let model = self.form.model();
|
||||
let req = create_group::Variables {
|
||||
name: model.groupname,
|
||||
group: create_group::CreateGroupInput {
|
||||
displayName: model.groupname,
|
||||
attributes,
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateGroup, _>(
|
||||
ctx,
|
||||
@@ -63,11 +127,16 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
|
||||
Msg::CreateGroupResponse(response) => {
|
||||
log!(&format!(
|
||||
"Created group '{}'",
|
||||
&response?.create_group.display_name
|
||||
&response?.create_group_with_details.display_name
|
||||
));
|
||||
ctx.link().history().unwrap().push(AppRoute::ListGroups);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes_schema =
|
||||
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,11 +149,22 @@ impl Component for CreateGroupForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
|
||||
}
|
||||
attributes_schema: None,
|
||||
form_ref: NodeRef::default(),
|
||||
};
|
||||
component
|
||||
.common
|
||||
.call_graphql::<GetGroupAttributesSchema, _>(
|
||||
ctx,
|
||||
get_group_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch group schema",
|
||||
);
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
@@ -93,44 +173,30 @@ impl Component for CreateGroupForm {
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
type Field = yew_form::Field<CreateGroupModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form py-3" style="max-width: 636px">
|
||||
<form class="form py-3" style="max-width: 636px"
|
||||
ref={self.form_ref.clone()}>
|
||||
<div class="row mb-3">
|
||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="groupname"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group name"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</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={link.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={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<Field<CreateGroupModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="Group name"
|
||||
field_name="groupname"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
{
|
||||
self.attributes_schema
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_readonly && a.name != "display_name")
|
||||
.map(get_custom_attribute_input)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
</form>
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
@@ -144,3 +210,21 @@ impl Component for CreateGroupForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={attribute_schema.attribute_type}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={attribute_schema.attribute_type}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::{AttributeType, validate_attribute_type},
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_validation::attributes::validate_attribute_name;
|
||||
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",
|
||||
extern_enums("AttributeType")
|
||||
)]
|
||||
pub struct CreateGroupAttribute;
|
||||
|
||||
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();
|
||||
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
|
||||
let invalid = String::from_iter(invalid_chars);
|
||||
bail!(
|
||||
"Attribute name contains one or more invalid characters: {}",
|
||||
invalid
|
||||
);
|
||||
})?;
|
||||
let attribute_type =
|
||||
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
|
||||
let req = create_group_attribute::Variables {
|
||||
name: model.attribute_name,
|
||||
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="JpegPhoto">{"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>
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
-175
@@ -1,11 +1,23 @@
|
||||
use crate::{
|
||||
components::router::AppRoute,
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
field::Field,
|
||||
submit::Submit,
|
||||
},
|
||||
router::AppRoute,
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{
|
||||
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
|
||||
read_all_form_attributes,
|
||||
},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{Result, ensure};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{opaque, registration};
|
||||
@@ -14,6 +26,31 @@ 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/get_user_attributes_schema.graphql",
|
||||
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql",
|
||||
extern_enums("AttributeType")
|
||||
)]
|
||||
pub struct GetUserAttributesSchema;
|
||||
|
||||
use get_user_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||
|
||||
impl From<&Attribute> for GraphQlAttributeSchema {
|
||||
fn from(attr: &Attribute) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
@@ -26,17 +63,14 @@ pub struct CreateUser;
|
||||
pub struct CreateUserForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<CreateUserModel>,
|
||||
attributes_schema: Option<Vec<Attribute>>,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||
pub struct CreateUserModel {
|
||||
#[validate(length(min = 1, message = "Username is required"))]
|
||||
username: String,
|
||||
#[validate(email(message = "A valid email is required"))]
|
||||
email: String,
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
#[validate(custom(
|
||||
function = "empty_or_long",
|
||||
message = "Password should be longer than 8 characters (or left empty)"
|
||||
@@ -56,6 +90,7 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
ListAttributesResponse(Result<ResponseData>),
|
||||
SubmitForm,
|
||||
CreateUserResponse(Result<create_user::ResponseData>),
|
||||
SuccessfulCreation,
|
||||
@@ -76,20 +111,43 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::ListAttributesResponse(schema) => {
|
||||
self.attributes_schema =
|
||||
Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitForm => {
|
||||
if !self.form.validate() {
|
||||
bail!("Check the form for errors");
|
||||
}
|
||||
ensure!(self.form.validate(), "Check the form for errors");
|
||||
|
||||
let all_values = read_all_form_attributes(
|
||||
self.attributes_schema.iter().flatten(),
|
||||
&self.form_ref,
|
||||
IsAdmin(true),
|
||||
EmailIsRequired(true),
|
||||
)?;
|
||||
let attributes = Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| create_user::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let model = self.form.model();
|
||||
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
|
||||
let req = create_user::Variables {
|
||||
user: create_user::CreateUserInput {
|
||||
id: model.username,
|
||||
email: model.email,
|
||||
displayName: to_option(model.display_name),
|
||||
firstName: to_option(model.first_name),
|
||||
lastName: to_option(model.last_name),
|
||||
email: None,
|
||||
displayName: None,
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
avatar: None,
|
||||
attributes,
|
||||
},
|
||||
};
|
||||
self.common.call_graphql::<CreateUser, _>(
|
||||
@@ -117,9 +175,12 @@ 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
|
||||
@@ -170,11 +231,20 @@ impl Component for CreateUserForm {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut component = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||
}
|
||||
attributes_schema: None,
|
||||
form_ref: NodeRef::default(),
|
||||
};
|
||||
component.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||
ctx,
|
||||
get_user_attributes_schema::Variables {},
|
||||
Msg::ListAttributesResponse,
|
||||
"Error trying to fetch user schema",
|
||||
);
|
||||
component
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
@@ -183,163 +253,41 @@ impl Component for CreateUserForm {
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
type Field = yew_form::Field<CreateUserModel>;
|
||||
html! {
|
||||
<div class="row justify-content-center">
|
||||
<form class="form 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"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</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={link.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"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</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={link.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={link.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={link.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={link.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={link.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={link.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={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Submit"}
|
||||
</button>
|
||||
</div>
|
||||
<form class="form py-3"
|
||||
ref={self.form_ref.clone()}>
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
required=true
|
||||
label="User name"
|
||||
field_name="username"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
{
|
||||
self.attributes_schema
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|a| !a.is_readonly)
|
||||
.map(get_custom_attribute_input)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Password"
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Field<CreateUserModel>
|
||||
form={&self.form}
|
||||
label="Confirm password"
|
||||
field_name="confirm_password"
|
||||
input_type="password"
|
||||
autocomplete="new-password"
|
||||
oninput={link.callback(|_| Msg::Update)} />
|
||||
<Submit
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
@@ -354,3 +302,25 @@ impl Component for CreateUserForm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||
let mail_is_required = attribute_schema.name.as_str() == "mail";
|
||||
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={attribute_schema.attribute_type}
|
||||
required={mail_is_required}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={attribute_schema.attribute_type}
|
||||
required={mail_is_required}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||
router::AppRoute,
|
||||
},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::{AttributeType, validate_attribute_type},
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use gloo_console::log;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_validation::attributes::validate_attribute_name;
|
||||
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",
|
||||
extern_enums("AttributeType")
|
||||
)]
|
||||
pub struct CreateUserAttribute;
|
||||
|
||||
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");
|
||||
}
|
||||
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
|
||||
let invalid = String::from_iter(invalid_chars);
|
||||
bail!(
|
||||
"Attribute name contains one or more invalid characters: {}",
|
||||
invalid
|
||||
);
|
||||
})?;
|
||||
let attribute_type =
|
||||
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
|
||||
let req = create_user_attribute::Variables {
|
||||
name: model.attribute_name,
|
||||
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="JpegPhoto">{"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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use crate::{
|
||||
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
|
||||
infra::{schema::AttributeType, tooltip::Tooltip},
|
||||
};
|
||||
use web_sys::Element;
|
||||
use yew::{
|
||||
Component, Context, Html, Properties, function_component, html, use_effect_with_deps,
|
||||
use_node_ref, virtual_dom::AttrValue,
|
||||
};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct AttributeInputProps {
|
||||
name: AttrValue,
|
||||
attribute_type: AttributeType,
|
||||
#[prop_or(None)]
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(AttributeInput)]
|
||||
fn attribute_input(props: &AttributeInputProps) -> Html {
|
||||
let input_type = match props.attribute_type {
|
||||
AttributeType::String => "text",
|
||||
AttributeType::Integer => "number",
|
||||
AttributeType::DateTime => {
|
||||
return html! {
|
||||
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
||||
};
|
||||
}
|
||||
AttributeType::JpegPhoto => {
|
||||
return html! {
|
||||
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<input
|
||||
type={input_type}
|
||||
name={props.name.clone()}
|
||||
class="form-control"
|
||||
value={props.value.clone()} />
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct AttributeLabelProps {
|
||||
pub name: String,
|
||||
#[prop_or(false)]
|
||||
pub required: bool,
|
||||
}
|
||||
#[function_component(AttributeLabel)]
|
||||
fn attribute_label(props: &AttributeLabelProps) -> Html {
|
||||
let tooltip_ref = use_node_ref();
|
||||
|
||||
use_effect_with_deps(
|
||||
move |tooltip_ref| {
|
||||
Tooltip::new(
|
||||
tooltip_ref
|
||||
.cast::<Element>()
|
||||
.expect("Tooltip element should exist"),
|
||||
);
|
||||
|| {}
|
||||
},
|
||||
tooltip_ref.clone(),
|
||||
);
|
||||
|
||||
html! {
|
||||
<label for={props.name.clone()}
|
||||
class="form-label col-4 col-form-label"
|
||||
>
|
||||
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}
|
||||
{if props.required { html!{<span class="text-danger">{"*"}</span>} } else { html!{} }}
|
||||
{":"}
|
||||
<button
|
||||
class="btn btn-sm btn-link"
|
||||
type="button"
|
||||
data-bs-placement="right"
|
||||
title={props.name.clone()}
|
||||
ref={tooltip_ref}>
|
||||
<i class="bi bi-info-circle" aria-label="Info" />
|
||||
</button>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SingleAttributeInputProps {
|
||||
pub name: String,
|
||||
pub(crate) attribute_type: AttributeType,
|
||||
#[prop_or(None)]
|
||||
pub value: Option<String>,
|
||||
#[prop_or(false)]
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[function_component(SingleAttributeInput)]
|
||||
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<AttributeLabel name={props.name.clone()} required={props.required} />
|
||||
<div class="col-8">
|
||||
<AttributeInput
|
||||
attribute_type={props.attribute_type}
|
||||
name={props.name.clone()}
|
||||
value={props.value.clone()} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ListAttributeInputProps {
|
||||
pub name: String,
|
||||
pub(crate) attribute_type: AttributeType,
|
||||
#[prop_or(vec!())]
|
||||
pub values: Vec<String>,
|
||||
#[prop_or(false)]
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
pub enum ListAttributeInputMsg {
|
||||
Remove(usize),
|
||||
Append,
|
||||
}
|
||||
|
||||
pub struct ListAttributeInput {
|
||||
indices: Vec<usize>,
|
||||
next_index: usize,
|
||||
values: Vec<String>,
|
||||
}
|
||||
impl Component for ListAttributeInput {
|
||||
type Message = ListAttributeInputMsg;
|
||||
type Properties = ListAttributeInputProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let values = ctx.props().values.clone();
|
||||
Self {
|
||||
indices: (0..values.len()).collect(),
|
||||
next_index: values.len(),
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ListAttributeInputMsg::Remove(removed) => {
|
||||
self.indices.retain_mut(|x| *x != removed);
|
||||
}
|
||||
ListAttributeInputMsg::Append => {
|
||||
self.indices.push(self.next_index);
|
||||
self.next_index += 1;
|
||||
}
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||
if ctx.props().values != self.values {
|
||||
self.values.clone_from(&ctx.props().values);
|
||||
self.indices = (0..self.values.len()).collect();
|
||||
self.next_index = self.values.len();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let props = &ctx.props();
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div class="row mb-3">
|
||||
<AttributeLabel name={props.name.clone()} required={props.required} />
|
||||
<div class="col-8">
|
||||
{self.indices.iter().map(|&i| html! {
|
||||
<div class="input-group mb-2" key={i}>
|
||||
<AttributeInput
|
||||
attribute_type={props.attribute_type}
|
||||
name={props.name.clone()}
|
||||
value={props.values.get(i).cloned().unwrap_or_default()} />
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
|
||||
<i class="bi-x-circle-fill" aria-label="Remove value" />
|
||||
</button>
|
||||
</div>
|
||||
}).collect::<Html>()}
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
|
||||
<i class="bi-plus-circle me-2"></i>
|
||||
{"Add value"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use yew::{Callback, Properties, function_component, html, virtual_dom::AttrValue};
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::{Event, Properties, function_component, html, use_state, virtual_dom::AttrValue};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct DateTimeInputProps {
|
||||
pub name: AttrValue,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(DateTimeInput)]
|
||||
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
|
||||
let value = use_state(|| {
|
||||
props
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="hidden"
|
||||
name={props.name.clone()}
|
||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
class="form-control"
|
||||
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
|
||||
onchange={move |e: Event| {
|
||||
let string_val =
|
||||
e.target()
|
||||
.expect("Event should have target")
|
||||
.unchecked_into::<HtmlInputElement>()
|
||||
.value();
|
||||
value.set(
|
||||
NaiveDateTime::from_str(&string_val)
|
||||
.ok()
|
||||
.map(|x| DateTime::from_naive_utc_and_offset(x, Utc))
|
||||
)
|
||||
}} />
|
||||
<span class="input-group-text">{"UTC"}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use yew::{Callback, InputEvent, Properties, function_component, html, virtual_dom::AttrValue};
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use anyhow::{Error, Ok, Result, bail};
|
||||
use gloo_file::{
|
||||
File,
|
||||
callbacks::{FileReader, read_as_bytes},
|
||||
};
|
||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||
use yew::Properties;
|
||||
use yew::{prelude::*, virtual_dom::AttrValue};
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
file: Option<File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Display for JsFile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for JsFile {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
if s.is_empty() {
|
||||
Ok(JsFile::default())
|
||||
} else {
|
||||
bail!("Building file from non-empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_base64(file: &JsFile) -> Result<String> {
|
||||
match file {
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: None,
|
||||
} => Ok(String::new()),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} => bail!("Image file hasn't finished loading, try again"),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: Some(data),
|
||||
} => {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
}
|
||||
Ok(base64::encode(data))
|
||||
}
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: Some(data),
|
||||
} => Ok(base64::encode(data)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||
pub struct JpegFileInput {
|
||||
// None means that the avatar hasn't changed.
|
||||
avatar: Option<JsFile>,
|
||||
reader: Option<FileReader>,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Update,
|
||||
/// A new file was selected.
|
||||
FileSelected(File),
|
||||
/// The "Clear" button for the avatar was clicked.
|
||||
ClearClicked,
|
||||
/// A picked file finished loading.
|
||||
FileLoaded(String, Result<Vec<u8>>),
|
||||
}
|
||||
|
||||
#[derive(Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub name: AttrValue,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
impl Component for JpegFileInput {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
avatar: Some(JsFile {
|
||||
file: None,
|
||||
contents: ctx
|
||||
.props()
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|x| base64::decode(x).ok()),
|
||||
}),
|
||||
reader: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||
self.avatar = Some(JsFile {
|
||||
file: None,
|
||||
contents: ctx
|
||||
.props()
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|x| base64::decode(x).ok()),
|
||||
});
|
||||
self.reader = None;
|
||||
true
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
Msg::Update => true,
|
||||
Msg::FileSelected(new_avatar) => {
|
||||
if self
|
||||
.avatar
|
||||
.as_ref()
|
||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||
!= Some(new_avatar.name())
|
||||
{
|
||||
let file_name = new_avatar.name();
|
||||
let link = ctx.link().clone();
|
||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||
link.send_message(Msg::FileLoaded(
|
||||
file_name,
|
||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||
))
|
||||
}));
|
||||
self.avatar = Some(JsFile {
|
||||
file: Some(new_avatar),
|
||||
contents: None,
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::ClearClicked => {
|
||||
self.avatar = Some(JsFile::default());
|
||||
true
|
||||
}
|
||||
Msg::FileLoaded(file_name, data) => {
|
||||
if let Some(avatar) = &mut self.avatar
|
||||
&& let Some(file) = &avatar.file
|
||||
&& file.name() == file_name
|
||||
&& let Result::Ok(data) = data
|
||||
{
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = Some(JsFile::default());
|
||||
// TODO: bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
avatar.contents = Some(data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.reader = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
|
||||
let avatar_string = match &self.avatar {
|
||||
Some(avatar) => {
|
||||
let avatar_base64 = to_base64(avatar);
|
||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
html! {
|
||||
<div class="row align-items-center">
|
||||
<div class="col-5">
|
||||
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
|
||||
<input
|
||||
class="form-control"
|
||||
id="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
Self::upload_files(input.files())
|
||||
})} />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button
|
||||
class="btn btn-secondary col-auto"
|
||||
id="avatarClear"
|
||||
type="button"
|
||||
onclick={link.callback(|_| {Msg::ClearClicked})}>
|
||||
{"Clear"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
{
|
||||
if !avatar_string.is_empty() {
|
||||
html!{
|
||||
<img
|
||||
id="avatarDisplay"
|
||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||
alt="Avatar" />
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JpegFileInput {
|
||||
fn upload_files(files: Option<FileList>) -> Msg {
|
||||
match files {
|
||||
Some(files) if files.length() > 0 => {
|
||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||
}
|
||||
Some(_) | None => Msg::Update,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||
.decode()
|
||||
.is_ok()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod attribute_input;
|
||||
pub mod checkbox;
|
||||
pub mod date_input;
|
||||
pub mod field;
|
||||
pub mod file_input;
|
||||
pub mod select;
|
||||
pub mod static_value;
|
||||
pub mod submit;
|
||||
@@ -0,0 +1,46 @@
|
||||
use yew::{
|
||||
Callback, Children, InputEvent, Properties, function_component, html, virtual_dom::AttrValue,
|
||||
};
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use yew::{Children, Properties, function_component, html, virtual_dom::AttrValue};
|
||||
|
||||
#[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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use web_sys::MouseEvent;
|
||||
use yew::{Callback, Children, Properties, function_component, html, virtual_dom::AttrValue};
|
||||
|
||||
#[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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::infra::attributes::AttributeDescription;
|
||||
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
|
||||
use yew::{Html, html};
|
||||
|
||||
fn render_attribute_aliases(attribute_description: &AttributeDescription) -> Html {
|
||||
if attribute_description.aliases.is_empty() {
|
||||
html! {}
|
||||
} else {
|
||||
html! {
|
||||
<>
|
||||
<br/>
|
||||
<small class="text-muted">
|
||||
{"Aliases: "}
|
||||
{attribute_description.aliases.join(", ")}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_attribute_validation_warnings(attribute_name: &str) -> Html {
|
||||
match validate_attribute_name(attribute_name) {
|
||||
Ok(()) => {
|
||||
html! {}
|
||||
}
|
||||
Err(_invalid_chars) => {
|
||||
html! {
|
||||
<>
|
||||
<br/>
|
||||
<small class="text-warning">
|
||||
{"Warning: This attribute uses one or more invalid characters "}
|
||||
{"("}{ALLOWED_CHARACTERS_DESCRIPTION}{"). "}
|
||||
{"Some clients may not support it."}
|
||||
</small>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_attribute_name(
|
||||
hardcoded: bool,
|
||||
attribute_description: &AttributeDescription,
|
||||
) -> Html {
|
||||
html! {
|
||||
<>
|
||||
{&attribute_description.attribute_name}
|
||||
{if hardcoded {render_attribute_aliases(attribute_description)} else {html!{}}}
|
||||
{render_attribute_validation_warnings(attribute_description.attribute_name)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod attribute_schema;
|
||||
@@ -1,12 +1,17 @@
|
||||
use crate::{
|
||||
components::{
|
||||
add_group_member::{self, AddGroupMemberComponent},
|
||||
group_details_form::GroupDetailsForm,
|
||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::GraphQlAttributeSchema,
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use anyhow::{Error, Result, bail};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
@@ -15,19 +20,33 @@ use yew::prelude::*;
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_group_details.graphql",
|
||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
custom_scalars_module = "crate::infra::graphql",
|
||||
extern_enums("AttributeType")
|
||||
)]
|
||||
pub struct GetGroupDetails;
|
||||
|
||||
pub type Group = get_group_details::GetGroupDetailsGroup;
|
||||
pub type User = get_group_details::GetGroupDetailsGroupUsers;
|
||||
pub type AddGroupMemberUser = add_group_member::User;
|
||||
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
|
||||
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
|
||||
|
||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||
fn from(attr: &AttributeSchema) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GroupDetails {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
group: Option<Group>,
|
||||
group_and_schema: Option<(Group, Vec<AttributeSchema>)>,
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -38,11 +57,13 @@ pub enum Msg {
|
||||
OnError(Error),
|
||||
OnUserAddedToGroup(AddGroupMemberUser),
|
||||
OnUserRemovedFromGroup((String, i64)),
|
||||
DisplayNameUpdated,
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||
pub struct Props {
|
||||
pub group_id: i64,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl GroupDetails {
|
||||
@@ -69,41 +90,16 @@ impl GroupDetails {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_details(&self, g: &Group) -> Html {
|
||||
fn view_details(&self, ctx: &Context<Self>, g: &Group, schema: Vec<AttributeSchema>) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<h3>{g.display_name.to_string()}</h3>
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="displayName"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Group: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="uuid"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<GroupDetailsForm
|
||||
group={g.clone()}
|
||||
group_attributes_schema={schema}
|
||||
is_admin={ctx.props().is_admin}
|
||||
on_display_name_updated={ctx.link().callback(|_| Msg::DisplayNameUpdated)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -182,29 +178,38 @@ impl GroupDetails {
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetails> for GroupDetails {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::GroupDetailsResponse(response) => match response {
|
||||
Ok(group) => self.group = Some(group.group),
|
||||
Ok(group) => {
|
||||
self.group_and_schema =
|
||||
Some((group.group, group.schema.group_schema.attributes))
|
||||
}
|
||||
Err(e) => {
|
||||
self.group = None;
|
||||
self.group_and_schema = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(user) => {
|
||||
self.group.as_mut().unwrap().users.push(User {
|
||||
self.group_and_schema.as_mut().unwrap().0.users.push(User {
|
||||
id: user.id,
|
||||
display_name: user.display_name,
|
||||
});
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((user_id, _)) => {
|
||||
self.group
|
||||
self.group_and_schema
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.0
|
||||
.users
|
||||
.retain(|u| u.id != user_id);
|
||||
}
|
||||
Msg::DisplayNameUpdated => self.get_group_details(ctx),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -221,7 +226,7 @@ impl Component for GroupDetails {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
group: None,
|
||||
group_and_schema: None,
|
||||
};
|
||||
table.get_group_details(ctx);
|
||||
table
|
||||
@@ -232,15 +237,15 @@ impl Component for GroupDetails {
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
match (&self.group, &self.common.error) {
|
||||
match (&self.group_and_schema, &self.common.error) {
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
(Some(u), error) => {
|
||||
(Some((group, schema)), error) => {
|
||||
html! {
|
||||
<div>
|
||||
{self.view_details(u)}
|
||||
{self.view_user_list(ctx, u)}
|
||||
{self.view_add_user_button(ctx, u)}
|
||||
{self.view_details(ctx, group, schema.clone())}
|
||||
{self.view_user_list(ctx, group)}
|
||||
{self.view_add_user_button(ctx, group)}
|
||||
{self.view_messages(error)}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
use crate::{
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
static_value::StaticValue,
|
||||
submit::Submit,
|
||||
},
|
||||
group_details::{Attribute, AttributeSchema, Group},
|
||||
},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
|
||||
},
|
||||
};
|
||||
use anyhow::{Ok, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
/// The GraphQL query sent to the server to update the group details.
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/update_group.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Clone,PartialEq,Eq",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
pub struct UpdateGroup;
|
||||
|
||||
/// A [yew::Component] to display the group details, with a form allowing to edit them.
|
||||
pub struct GroupDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// True if we just successfully updated the group, to display a success message.
|
||||
just_updated: bool,
|
||||
updated_group_name: bool,
|
||||
group: Group,
|
||||
form_ref: NodeRef,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
/// A form field changed.
|
||||
Update,
|
||||
/// The "Submit" button was clicked.
|
||||
SubmitClicked,
|
||||
/// We got the response from the server about our update message.
|
||||
GroupUpdated(Result<update_group::ResponseData>),
|
||||
}
|
||||
|
||||
#[derive(yew::Properties, Clone, PartialEq)]
|
||||
pub struct Props {
|
||||
/// The current group details.
|
||||
pub group: Group,
|
||||
pub group_attributes_schema: Vec<AttributeSchema>,
|
||||
pub is_admin: bool,
|
||||
pub on_display_name_updated: Callback<()>,
|
||||
}
|
||||
|
||||
impl CommonComponent<GroupDetailsForm> for GroupDetailsForm {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::SubmitClicked => self.submit_group_update_form(ctx),
|
||||
Msg::GroupUpdated(Err(e)) => Err(e),
|
||||
Msg::GroupUpdated(Result::Ok(_)) => {
|
||||
self.just_updated = true;
|
||||
if self.updated_group_name {
|
||||
self.updated_group_name = false;
|
||||
ctx.props().on_display_name_updated.emit(());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GroupDetailsForm {
|
||||
type Message = Msg;
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
just_updated: false,
|
||||
updated_group_name: false,
|
||||
group: ctx.props().group.clone(),
|
||||
form_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
self.just_updated = false;
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = &ctx.link();
|
||||
|
||||
let can_edit =
|
||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||
let display_field = |a: &AttributeSchema| {
|
||||
if can_edit(a) {
|
||||
get_custom_attribute_input(a, &self.group.attributes)
|
||||
} else {
|
||||
get_custom_attribute_static(a, &self.group.attributes)
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form
|
||||
class="form"
|
||||
ref={self.form_ref.clone()}>
|
||||
<StaticValue label="Group ID" id="groupId">
|
||||
<i>{&self.group.id}</i>
|
||||
</StaticValue>
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.group_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded && a.name != "group_id")
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.group_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| !a.is_hardcoded)
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
text="Save changes"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
html! {
|
||||
<div class="alert alert-danger">
|
||||
{e.to_string() }
|
||||
</div>
|
||||
}
|
||||
} else { html! {} }
|
||||
}
|
||||
<div hidden={!self.just_updated}>
|
||||
<div class="alert alert-success mt-4">{"Group successfully updated!"}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(
|
||||
attribute_schema: &AttributeSchema,
|
||||
group_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = group_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={attribute_schema.attribute_type}
|
||||
values={values}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={attribute_schema.attribute_type}
|
||||
value={values.first().cloned().unwrap_or_default()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_static(
|
||||
attribute_schema: &AttributeSchema,
|
||||
group_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = group_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
html! {
|
||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||
</StaticValue>
|
||||
}
|
||||
}
|
||||
|
||||
impl GroupDetailsForm {
|
||||
fn submit_group_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
let mut all_values = read_all_form_attributes(
|
||||
ctx.props().group_attributes_schema.iter(),
|
||||
&self.form_ref,
|
||||
IsAdmin(ctx.props().is_admin),
|
||||
EmailIsRequired(false),
|
||||
)?;
|
||||
let base_attributes = &self.group.attributes;
|
||||
all_values.retain(|a| {
|
||||
let base_val = base_attributes
|
||||
.iter()
|
||||
.find(|base_val| base_val.name == a.name);
|
||||
base_val
|
||||
.map(|v| v.value != a.values)
|
||||
.unwrap_or(!a.values.is_empty())
|
||||
});
|
||||
if all_values.iter().any(|a| a.name == "display_name") {
|
||||
self.updated_group_name = true;
|
||||
}
|
||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||
};
|
||||
let insert_attributes: Option<Vec<update_group::AttributeValueInput>> =
|
||||
if remove_attributes.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| update_group::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let mut group_input = update_group::UpdateGroupInput {
|
||||
id: self.group.id,
|
||||
displayName: None,
|
||||
removeAttributes: None,
|
||||
insertAttributes: None,
|
||||
};
|
||||
let default_group_input = group_input.clone();
|
||||
group_input.removeAttributes = remove_attributes;
|
||||
group_input.insertAttributes = insert_attributes;
|
||||
// Nothing changed.
|
||||
if group_input == default_group_input {
|
||||
return Ok(false);
|
||||
}
|
||||
let req = update_group::Variables { group: group_input };
|
||||
self.common.call_graphql::<UpdateGroup, _>(
|
||||
ctx,
|
||||
req,
|
||||
Msg::GroupUpdated,
|
||||
"Error trying to update group",
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use crate::{
|
||||
components::{
|
||||
delete_group_attribute::DeleteGroupAttribute,
|
||||
fragments::attribute_schema::render_attribute_name,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
attributes::group,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{Error, Result, anyhow};
|
||||
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",
|
||||
extern_enums("AttributeType")
|
||||
)]
|
||||
pub struct GetGroupAttributesSchema;
|
||||
|
||||
use get_group_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute =
|
||||
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||
|
||||
#[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 = attribute.attribute_type;
|
||||
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;
|
||||
let desc = group::resolve_group_attribute_description_or_default(&attribute.name);
|
||||
html! {
|
||||
<tr key={attribute.name.clone()}>
|
||||
<td>{render_attribute_name(hardcoded, &desc)}</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>
|
||||
}
|
||||
}
|
||||
+61
-64
@@ -1,11 +1,14 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, Link},
|
||||
components::{
|
||||
form::submit::Submit,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use gloo_console::error;
|
||||
use lldap_auth::*;
|
||||
use validator_derive::Validate;
|
||||
@@ -24,7 +27,7 @@ pub struct LoginForm {
|
||||
pub struct FormModel {
|
||||
#[validate(length(min = 1, message = "Missing username"))]
|
||||
username: String,
|
||||
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
|
||||
#[validate(length(min = 1, message = "Missing password"))]
|
||||
password: String,
|
||||
}
|
||||
|
||||
@@ -66,7 +69,7 @@ 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
|
||||
@@ -155,68 +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={link.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={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||
<i class="bi-box-arrow-in-right me-2"/>
|
||||
{"Login"}
|
||||
</button>
|
||||
{ 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!{}
|
||||
}}
|
||||
</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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
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 fragments;
|
||||
pub mod group_details;
|
||||
pub mod group_details_form;
|
||||
pub mod group_schema_table;
|
||||
pub mod group_table;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
@@ -17,4 +27,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;
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{Result, bail};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
@@ -104,7 +104,11 @@ impl Component for ResetPasswordStep1Form {
|
||||
</div>
|
||||
{ if self.just_succeeded {
|
||||
html! {
|
||||
{"A reset token has been sent to your email."}
|
||||
{"If a user with this username or email exists, a password reset email will \
|
||||
be sent to the associated email address. Please check your email and \
|
||||
follow the instructions. If you don't receive an email, please check \
|
||||
your spam folder. If you still don't receive an email, please contact \
|
||||
your administrator."}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use crate::{
|
||||
components::router::{AppRoute, Link},
|
||||
components::{
|
||||
form::{field::Field, submit::Submit},
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
api::HostService,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{Result, bail};
|
||||
use lldap_auth::{
|
||||
opaque::client::registration as opaque_registration,
|
||||
password_reset::ServerPasswordResetResponse, registration,
|
||||
@@ -65,10 +68,10 @@ 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);
|
||||
@@ -145,7 +148,7 @@ impl Component for ResetPasswordStep2Form {
|
||||
(None, None) => {
|
||||
return html! {
|
||||
{"Validating token"}
|
||||
}
|
||||
};
|
||||
}
|
||||
(None, Some(e)) => {
|
||||
return html! {
|
||||
@@ -160,65 +163,33 @@ impl Component for ResetPasswordStep2Form {
|
||||
{"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={link.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={link.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={link.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! {
|
||||
|
||||
@@ -22,6 +22,14 @@ pub enum AppRoute {
|
||||
ListGroups,
|
||||
#[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,
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@ use crate::{
|
||||
router::{AppRoute, Link},
|
||||
user_details_form::UserDetailsForm,
|
||||
},
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::GraphQlAttributeSchema,
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use anyhow::{Error, Result, bail};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use yew::prelude::*;
|
||||
|
||||
@@ -16,18 +20,38 @@ use yew::prelude::*;
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/get_user_details.graphql",
|
||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
custom_scalars_module = "crate::infra::graphql",
|
||||
extern_enums("AttributeType")
|
||||
)]
|
||||
pub struct GetUserDetails;
|
||||
|
||||
pub type User = get_user_details::GetUserDetailsUser;
|
||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
||||
|
||||
impl From<&AttributeSchema> for GraphQlAttributeSchema {
|
||||
fn from(attr: &AttributeSchema) -> Self {
|
||||
Self {
|
||||
name: attr.name.clone(),
|
||||
is_list: attr.is_list,
|
||||
is_readonly: attr.is_readonly,
|
||||
is_editable: attr.is_editable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UserDetails {
|
||||
common: CommonComponentParts<Self>,
|
||||
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||
/// received the server response yet.
|
||||
user: Option<User>,
|
||||
user_and_schema: Option<(User, Vec<AttributeSchema>)>,
|
||||
}
|
||||
|
||||
impl UserDetails {
|
||||
fn mut_groups(&mut self) -> &mut Vec<Group> {
|
||||
&mut self.user_and_schema.as_mut().unwrap().0.groups
|
||||
}
|
||||
}
|
||||
|
||||
/// State machine describing the possible transitions of the component state.
|
||||
@@ -50,22 +74,20 @@ impl CommonComponent<UserDetails> for UserDetails {
|
||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::UserDetailsResponse(response) => match response {
|
||||
Ok(user) => self.user = Some(user.user),
|
||||
Ok(user) => {
|
||||
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
|
||||
}
|
||||
Err(e) => {
|
||||
self.user = None;
|
||||
self.user_and_schema = None;
|
||||
bail!("Error getting user details: {}", e);
|
||||
}
|
||||
},
|
||||
Msg::OnError(e) => return Err(e),
|
||||
Msg::OnUserAddedToGroup(group) => {
|
||||
self.user.as_mut().unwrap().groups.push(group);
|
||||
self.mut_groups().push(group);
|
||||
}
|
||||
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
||||
self.user
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.groups
|
||||
.retain(|g| g.id != group_id);
|
||||
self.mut_groups().retain(|g| g.id != group_id);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -178,7 +200,7 @@ impl Component for UserDetails {
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut table = Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
user: None,
|
||||
user_and_schema: None,
|
||||
};
|
||||
table.get_user_details(ctx);
|
||||
table
|
||||
@@ -189,10 +211,8 @@ impl Component for UserDetails {
|
||||
}
|
||||
|
||||
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>},
|
||||
(Some(u), error) => {
|
||||
match (&self.user_and_schema, &self.common.error) {
|
||||
(Some((u, schema)), error) => {
|
||||
html! {
|
||||
<>
|
||||
<h3>{u.id.to_string()}</h3>
|
||||
@@ -207,13 +227,20 @@ impl Component for UserDetails {
|
||||
<div>
|
||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||
</div>
|
||||
<UserDetailsForm user={u.clone()} />
|
||||
<UserDetailsForm
|
||||
user={u.clone()}
|
||||
user_attributes_schema={schema.clone()}
|
||||
is_admin={ctx.props().is_admin}
|
||||
is_edited_user_admin={u.groups.iter().any(|g| g.display_name == "lldap_admin")}
|
||||
/>
|
||||
{self.view_group_memberships(ctx, u)}
|
||||
{self.view_add_group_button(ctx, u)}
|
||||
{self.view_messages(error)}
|
||||
</>
|
||||
}
|
||||
}
|
||||
(None, None) => html! {{"Loading..."}},
|
||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,22 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
components::user_details::User,
|
||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||
};
|
||||
use anyhow::{bail, Error, Result};
|
||||
use gloo_file::{
|
||||
callbacks::{read_as_bytes, FileReader},
|
||||
File,
|
||||
components::{
|
||||
form::{
|
||||
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||
static_value::StaticValue,
|
||||
submit::Submit,
|
||||
},
|
||||
user_details::{Attribute, AttributeSchema, User},
|
||||
},
|
||||
infra::{
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{Ok, Result};
|
||||
use gloo_console::console;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use validator_derive::Validate;
|
||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||
use yew::prelude::*;
|
||||
use yew_form_derive::Model;
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsFile {
|
||||
file: Option<File>,
|
||||
contents: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ToString for JsFile {
|
||||
fn to_string(&self) -> String {
|
||||
self.file
|
||||
.as_ref()
|
||||
.map(File::name)
|
||||
.unwrap_or_else(String::new)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for JsFile {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
if s.is_empty() {
|
||||
Ok(JsFile::default())
|
||||
} else {
|
||||
bail!("Building file from non-empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of the form, with the editable details and the constraints.
|
||||
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
||||
pub struct UserModel {
|
||||
#[validate(email)]
|
||||
email: String,
|
||||
display_name: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
}
|
||||
|
||||
/// The GraphQL query sent to the server to update the user details.
|
||||
#[derive(GraphQLQuery)]
|
||||
@@ -66,23 +32,17 @@ pub struct UpdateUser;
|
||||
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||
pub struct UserDetailsForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: yew_form::Form<UserModel>,
|
||||
avatar: 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,
|
||||
/// A picked file finished loading.
|
||||
FileLoaded(String, Result<Vec<u8>>),
|
||||
/// We got the response from the server about our update message.
|
||||
UserUpdated(Result<update_user::ResponseData>),
|
||||
}
|
||||
@@ -91,6 +51,9 @@ pub enum Msg {
|
||||
pub struct Props {
|
||||
/// The current user details.
|
||||
pub user: User,
|
||||
pub user_attributes_schema: Vec<AttributeSchema>,
|
||||
pub is_admin: bool,
|
||||
pub is_edited_user_admin: bool,
|
||||
}
|
||||
|
||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
@@ -101,41 +64,11 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||
) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::Update => Ok(true),
|
||||
Msg::FileSelected(new_avatar) => {
|
||||
if self.avatar.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 = JsFile {
|
||||
file: Some(new_avatar),
|
||||
contents: None,
|
||||
};
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||
Msg::FileLoaded(file_name, data) => {
|
||||
if let Some(file) = &self.avatar.file {
|
||||
if file.name() == file_name {
|
||||
let data = data?;
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
// Clear the selection.
|
||||
self.avatar = JsFile::default();
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
} else {
|
||||
self.avatar.contents = Some(data);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reader = None;
|
||||
Ok(false)
|
||||
Msg::UserUpdated(Err(e)) => Err(e),
|
||||
Msg::UserUpdated(Result::Ok(_)) => {
|
||||
self.just_updated = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,19 +83,11 @@ impl Component for UserDetailsForm {
|
||||
type Properties = Props;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let model = UserModel {
|
||||
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(),
|
||||
form: yew_form::Form::new(model),
|
||||
avatar: JsFile::default(),
|
||||
just_updated: false,
|
||||
reader: None,
|
||||
user: ctx.props().user.clone(),
|
||||
form_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,156 +97,47 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
type Field = yew_form::Field<UserModel>;
|
||||
let link = &ctx.link();
|
||||
|
||||
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
|
||||
let avatar_string = avatar_base64
|
||||
.as_deref()
|
||||
.or(self.user.avatar.as_deref())
|
||||
.unwrap_or("");
|
||||
let can_edit =
|
||||
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||
let display_field = |a: &AttributeSchema| {
|
||||
if can_edit(a) {
|
||||
get_custom_attribute_input(a, &self.user.attributes)
|
||||
} else {
|
||||
get_custom_attribute_static(a, &self.user.attributes)
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<div class="py-3">
|
||||
<form class="form">
|
||||
<div class="form-group row mb-3">
|
||||
<label for="userId"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"User ID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="userId" class="form-control-static"><i>{&self.user.id}</i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="creationDate"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Creation date: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="uuid"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"UUID: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="email"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Email"}
|
||||
<span class="text-danger">{"*"}</span>
|
||||
{":"}
|
||||
</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={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("email")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row mb-3">
|
||||
<label for="display_name"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Display Name: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form={&self.form}
|
||||
field_name="display_name"
|
||||
autocomplete="name"
|
||||
oninput={link.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={link.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={link.callback(|_| Msg::Update)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("last_name")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row align-items-center mb-3">
|
||||
<label for="avatar"
|
||||
class="form-label col-4 col-form-label">
|
||||
{"Avatar: "}
|
||||
</label>
|
||||
<div class="col-8">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-8">
|
||||
<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-4">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row justify-content-center mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary col-auto col-form-label"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
|
||||
<i class="bi-save me-2"></i>
|
||||
{"Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
class="form"
|
||||
ref={self.form_ref.clone()}>
|
||||
<StaticValue label="User ID" id="userId">
|
||||
<i>{&self.user.id}</i>
|
||||
</StaticValue>
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.user_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| a.is_hardcoded && a.name != "user_id")
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
{
|
||||
ctx
|
||||
.props()
|
||||
.user_attributes_schema
|
||||
.iter()
|
||||
.filter(|a| !a.is_hardcoded)
|
||||
.map(display_field)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
<Submit
|
||||
text="Save changes"
|
||||
disabled={self.common.is_task_running()}
|
||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
|
||||
</form>
|
||||
{
|
||||
if let Some(e) = &self.common.error {
|
||||
@@ -340,19 +156,107 @@ impl Component for UserDetailsForm {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_input(
|
||||
attribute_schema: &AttributeSchema,
|
||||
user_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = user_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
if attribute_schema.is_list {
|
||||
html! {
|
||||
<ListAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={attribute_schema.attribute_type}
|
||||
values={values}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<SingleAttributeInput
|
||||
name={attribute_schema.name.clone()}
|
||||
attribute_type={attribute_schema.attribute_type}
|
||||
value={values.first().cloned().unwrap_or_default()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_custom_attribute_static(
|
||||
attribute_schema: &AttributeSchema,
|
||||
user_attributes: &[Attribute],
|
||||
) -> Html {
|
||||
let values = user_attributes
|
||||
.iter()
|
||||
.find(|a| a.name == attribute_schema.name)
|
||||
.map(|attribute| attribute.value.clone())
|
||||
.unwrap_or_default();
|
||||
let value_to_str = match attribute_schema.attribute_type {
|
||||
AttributeType::String | AttributeType::Integer => |v: String| v,
|
||||
AttributeType::DateTime => |v: String| {
|
||||
console!(format!("Parsing date: {}", &v));
|
||||
chrono::DateTime::parse_from_rfc3339(&v)
|
||||
.map(|dt| dt.naive_utc().to_string())
|
||||
.unwrap_or_else(|_| "Invalid date".to_string())
|
||||
},
|
||||
AttributeType::JpegPhoto => |_: String| "Unimplemented JPEG display".to_string(),
|
||||
};
|
||||
html! {
|
||||
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||
{values.into_iter().map(|x| html!{<div>{value_to_str(x)}</div>}).collect::<Vec<_>>()}
|
||||
</StaticValue>
|
||||
}
|
||||
}
|
||||
|
||||
impl UserDetailsForm {
|
||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||
if !self.form.validate() {
|
||||
bail!("Invalid inputs");
|
||||
}
|
||||
if let JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} = &self.avatar
|
||||
{
|
||||
bail!("Image file hasn't finished loading, try again");
|
||||
}
|
||||
let base_user = &self.user;
|
||||
// TODO: Handle unloaded files.
|
||||
// if let Some(JsFile {
|
||||
// file: Some(_),
|
||||
// contents: None,
|
||||
// }) = &self.avatar
|
||||
// {
|
||||
// bail!("Image file hasn't finished loading, try again");
|
||||
// }
|
||||
let mut all_values = read_all_form_attributes(
|
||||
ctx.props().user_attributes_schema.iter(),
|
||||
&self.form_ref,
|
||||
IsAdmin(ctx.props().is_admin),
|
||||
EmailIsRequired(!ctx.props().is_edited_user_admin),
|
||||
)?;
|
||||
let base_attributes = &self.user.attributes;
|
||||
all_values.retain(|a| {
|
||||
let base_val = base_attributes
|
||||
.iter()
|
||||
.find(|base_val| base_val.name == a.name);
|
||||
base_val
|
||||
.map(|v| v.value != a.values)
|
||||
.unwrap_or(!a.values.is_empty())
|
||||
});
|
||||
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(all_values.iter().map(|a| a.name.clone()).collect())
|
||||
};
|
||||
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
|
||||
if remove_attributes.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
all_values
|
||||
.into_iter()
|
||||
.filter(|a| !a.values.is_empty())
|
||||
.map(
|
||||
|AttributeValue { name, values }| update_user::AttributeValueInput {
|
||||
name,
|
||||
value: values,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let mut user_input = update_user::UpdateUserInput {
|
||||
id: self.user.id.clone(),
|
||||
email: None,
|
||||
@@ -360,23 +264,12 @@ impl UserDetailsForm {
|
||||
firstName: None,
|
||||
lastName: None,
|
||||
avatar: None,
|
||||
removeAttributes: None,
|
||||
insertAttributes: None,
|
||||
};
|
||||
let default_user_input = user_input.clone();
|
||||
let model = self.form.model();
|
||||
let email = model.email;
|
||||
if base_user.email != email {
|
||||
user_input.email = Some(email);
|
||||
}
|
||||
if base_user.display_name != model.display_name {
|
||||
user_input.displayName = Some(model.display_name);
|
||||
}
|
||||
if base_user.first_name != model.first_name {
|
||||
user_input.firstName = Some(model.first_name);
|
||||
}
|
||||
if base_user.last_name != model.last_name {
|
||||
user_input.lastName = Some(model.last_name);
|
||||
}
|
||||
user_input.avatar = maybe_to_base64(&self.avatar)?;
|
||||
user_input.removeAttributes = remove_attributes;
|
||||
user_input.insertAttributes = insert_attributes;
|
||||
// Nothing changed.
|
||||
if user_input == default_user_input {
|
||||
return Ok(false);
|
||||
@@ -390,58 +283,4 @@ impl UserDetailsForm {
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
|
||||
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) = maybe_to_base64(&self.avatar)? {
|
||||
self.user.avatar = Some(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 {
|
||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||
.decode()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn maybe_to_base64(file: &JsFile) -> Result<Option<String>> {
|
||||
match file {
|
||||
JsFile {
|
||||
file: None,
|
||||
contents: _,
|
||||
} => Ok(None),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: None,
|
||||
} => bail!("Image file hasn't finished loading, try again"),
|
||||
JsFile {
|
||||
file: Some(_),
|
||||
contents: Some(data),
|
||||
} => {
|
||||
if !is_valid_jpeg(data.as_slice()) {
|
||||
bail!("Chosen image is not a valid JPEG");
|
||||
}
|
||||
Ok(Some(base64::encode(data)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
use crate::{
|
||||
components::{
|
||||
delete_user_attribute::DeleteUserAttribute,
|
||||
fragments::attribute_schema::render_attribute_name,
|
||||
router::{AppRoute, Link},
|
||||
},
|
||||
infra::{
|
||||
attributes::user,
|
||||
common_component::{CommonComponent, CommonComponentParts},
|
||||
schema::AttributeType,
|
||||
},
|
||||
};
|
||||
use anyhow::{Error, Result, anyhow};
|
||||
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",
|
||||
extern_enums("AttributeType")
|
||||
)]
|
||||
pub struct GetUserAttributesSchema;
|
||||
|
||||
use get_user_attributes_schema::ResponseData;
|
||||
|
||||
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||
|
||||
#[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 = attribute.attribute_type;
|
||||
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;
|
||||
let desc = user::resolve_user_attribute_description_or_default(&attribute.name);
|
||||
html! {
|
||||
<tr key={attribute.name.clone()}>
|
||||
<td>{render_attribute_name(hardcoded, &desc)}</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>
|
||||
}
|
||||
}
|
||||
+62
-44
@@ -1,10 +1,11 @@
|
||||
use super::cookies::set_cookie;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gloo_net::http::{Method, Request};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use gloo_net::http::{Method, RequestBuilder};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use lldap_auth::{login, registration, JWTClaims};
|
||||
use lldap_auth::{JWTClaims, login, registration};
|
||||
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use lldap_frontend_options::Options;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use web_sys::RequestCredentials;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -16,21 +17,32 @@ fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
||||
Ok(token.claims().clone())
|
||||
}
|
||||
|
||||
const NO_BODY: Option<()> = None;
|
||||
enum RequestType<Body: Serialize> {
|
||||
Get,
|
||||
Post(Body),
|
||||
}
|
||||
|
||||
async fn call_server(
|
||||
const GET_REQUEST: RequestType<()> = RequestType::Get;
|
||||
|
||||
fn base_url() -> String {
|
||||
yew_router::utils::base_url().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn call_server<Body: Serialize>(
|
||||
url: &str,
|
||||
body: Option<impl Serialize>,
|
||||
body: RequestType<Body>,
|
||||
error_message: &'static str,
|
||||
) -> Result<String> {
|
||||
let mut request = Request::new(url)
|
||||
let request_builder = RequestBuilder::new(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.credentials(RequestCredentials::SameOrigin);
|
||||
if let Some(b) = body {
|
||||
request = request
|
||||
.body(serde_json::to_string(&b)?)
|
||||
.method(Method::POST);
|
||||
}
|
||||
let request = if let RequestType::Post(b) = body {
|
||||
request_builder
|
||||
.method(Method::POST)
|
||||
.body(serde_json::to_string(&b)?)?
|
||||
} else {
|
||||
request_builder.build()?
|
||||
};
|
||||
let response = request.send().await?;
|
||||
if response.ok() {
|
||||
Ok(response.text().await?)
|
||||
@@ -47,7 +59,7 @@ async fn call_server(
|
||||
|
||||
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||
url: &str,
|
||||
request: Option<Body>,
|
||||
request: RequestType<Body>,
|
||||
error_message: &'static str,
|
||||
) -> Result<CallbackResult>
|
||||
where
|
||||
@@ -59,7 +71,7 @@ where
|
||||
|
||||
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||
url: &str,
|
||||
request: Option<Body>,
|
||||
request: RequestType<Body>,
|
||||
error_message: &'static str,
|
||||
) -> Result<()> {
|
||||
call_server(url, request, error_message).await.map(|_| ())
|
||||
@@ -97,8 +109,8 @@ impl HostService {
|
||||
};
|
||||
let request_body = QueryType::build_query(variables);
|
||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||
"/api/graphql",
|
||||
Some(request_body),
|
||||
&(base_url() + "/api/graphql"),
|
||||
RequestType::Post(request_body),
|
||||
error_message,
|
||||
)
|
||||
.await
|
||||
@@ -109,8 +121,8 @@ impl HostService {
|
||||
request: login::ClientLoginStartRequest,
|
||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/login/start",
|
||||
Some(request),
|
||||
&(base_url() + "/auth/opaque/login/start"),
|
||||
RequestType::Post(request),
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
.await
|
||||
@@ -118,20 +130,29 @@ impl HostService {
|
||||
|
||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
"/auth/opaque/login/finish",
|
||||
Some(request),
|
||||
&(base_url() + "/auth/opaque/login/finish"),
|
||||
RequestType::Post(request),
|
||||
"Could not finish authentication",
|
||||
)
|
||||
.await
|
||||
.and_then(set_cookies_from_jwt)
|
||||
}
|
||||
|
||||
pub async fn get_settings() -> Result<Options> {
|
||||
call_server_json_with_error_message::<Options, _>(
|
||||
&(base_url() + "/settings"),
|
||||
GET_REQUEST,
|
||||
"Could not fetch settings: ",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn register_start(
|
||||
request: registration::ClientRegistrationStartRequest,
|
||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||
call_server_json_with_error_message(
|
||||
"/auth/opaque/register/start",
|
||||
Some(request),
|
||||
&(base_url() + "/auth/opaque/register/start"),
|
||||
RequestType::Post(request),
|
||||
"Could not start registration: ",
|
||||
)
|
||||
.await
|
||||
@@ -141,8 +162,8 @@ impl HostService {
|
||||
request: registration::ClientRegistrationFinishRequest,
|
||||
) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
"/auth/opaque/register/finish",
|
||||
Some(request),
|
||||
&(base_url() + "/auth/opaque/register/finish"),
|
||||
RequestType::Post(request),
|
||||
"Could not finish registration",
|
||||
)
|
||||
.await
|
||||
@@ -150,8 +171,8 @@ impl HostService {
|
||||
|
||||
pub async fn refresh() -> Result<(String, bool)> {
|
||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||
"/auth/refresh",
|
||||
NO_BODY,
|
||||
&(base_url() + "/auth/refresh"),
|
||||
GET_REQUEST,
|
||||
"Could not start authentication: ",
|
||||
)
|
||||
.await
|
||||
@@ -160,14 +181,22 @@ impl HostService {
|
||||
|
||||
// The `_request` parameter is to make it the same shape as the other functions.
|
||||
pub async fn logout() -> Result<()> {
|
||||
call_server_empty_response_with_error_message("/auth/logout", NO_BODY, "Could not logout")
|
||||
.await
|
||||
call_server_empty_response_with_error_message(
|
||||
&(base_url() + "/auth/logout"),
|
||||
GET_REQUEST,
|
||||
"Could not logout",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn reset_password_step1(username: String) -> Result<()> {
|
||||
call_server_empty_response_with_error_message(
|
||||
&format!("/auth/reset/step1/{}", url_escape::encode_query(&username)),
|
||||
NO_BODY,
|
||||
&format!(
|
||||
"{}/auth/reset/step1/{}",
|
||||
base_url(),
|
||||
url_escape::encode_query(&username)
|
||||
),
|
||||
RequestType::Post(""),
|
||||
"Could not initiate password reset",
|
||||
)
|
||||
.await
|
||||
@@ -177,21 +206,10 @@ impl HostService {
|
||||
token: String,
|
||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("/auth/reset/step2/{}", token),
|
||||
NO_BODY,
|
||||
&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("/auth/reset/step1/lldap_unlikely_very_long_user_name")
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?
|
||||
.status()
|
||||
!= http::StatusCode::NOT_FOUND,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
pub struct AttributeDescription<'a> {
|
||||
pub attribute_identifier: &'a str,
|
||||
pub attribute_name: &'a str,
|
||||
pub aliases: Vec<&'a str>,
|
||||
}
|
||||
|
||||
pub mod group {
|
||||
|
||||
use super::AttributeDescription;
|
||||
|
||||
pub fn resolve_group_attribute_description(name: &'_ str) -> Option<AttributeDescription<'_>> {
|
||||
match name {
|
||||
"creation_date" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "creationdate",
|
||||
aliases: vec![name, "createtimestamp"],
|
||||
}),
|
||||
"modified_date" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "modifydate",
|
||||
aliases: vec![name, "modifytimestamp"],
|
||||
}),
|
||||
"display_name" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "displayname",
|
||||
aliases: vec![name, "cn", "uid", "id"],
|
||||
}),
|
||||
"group_id" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "groupid",
|
||||
aliases: vec![name],
|
||||
}),
|
||||
"uuid" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec!["entryuuid"],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_group_attribute_description_or_default(
|
||||
name: &'_ str,
|
||||
) -> AttributeDescription<'_> {
|
||||
match resolve_group_attribute_description(name) {
|
||||
Some(d) => d,
|
||||
None => AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod user {
|
||||
|
||||
use super::AttributeDescription;
|
||||
|
||||
pub fn resolve_user_attribute_description(name: &'_ str) -> Option<AttributeDescription<'_>> {
|
||||
match name {
|
||||
"avatar" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec!["jpegphoto"],
|
||||
}),
|
||||
"creation_date" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "creationdate",
|
||||
aliases: vec![name, "createtimestamp"],
|
||||
}),
|
||||
"modified_date" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "modifydate",
|
||||
aliases: vec![name, "modifytimestamp"],
|
||||
}),
|
||||
"password_modified_date" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "passwordmodifydate",
|
||||
aliases: vec![name, "pwdchangedtime"],
|
||||
}),
|
||||
"display_name" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "displayname",
|
||||
aliases: vec![name, "cn"],
|
||||
}),
|
||||
"first_name" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "firstname",
|
||||
aliases: vec![name, "givenname"],
|
||||
}),
|
||||
"last_name" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "lastname",
|
||||
aliases: vec![name, "sn"],
|
||||
}),
|
||||
"mail" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec!["email"],
|
||||
}),
|
||||
"user_id" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: "uid",
|
||||
aliases: vec![name, "id"],
|
||||
}),
|
||||
"uuid" => Some(AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec!["entryuuid"],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_user_attribute_description_or_default(
|
||||
name: &'_ str,
|
||||
) -> AttributeDescription<'_> {
|
||||
match resolve_user_attribute_description(name) {
|
||||
Some(d) => d,
|
||||
None => AttributeDescription {
|
||||
attribute_identifier: name,
|
||||
attribute_name: name,
|
||||
aliases: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlDocument;
|
||||
@@ -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"))
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use anyhow::{Result, anyhow, ensure};
|
||||
use validator::validate_email;
|
||||
use web_sys::{FormData, HtmlFormElement};
|
||||
use yew::NodeRef;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AttributeValue {
|
||||
pub name: String,
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct GraphQlAttributeSchema {
|
||||
pub name: String,
|
||||
pub is_list: bool,
|
||||
pub is_readonly: bool,
|
||||
pub is_editable: bool,
|
||||
}
|
||||
|
||||
fn validate_email_attributes(all_values: &[AttributeValue]) -> Result<()> {
|
||||
let maybe_email_values = all_values.iter().find(|a| a.name == "mail");
|
||||
let email_values = &maybe_email_values
|
||||
.ok_or_else(|| anyhow!("Email is required"))?
|
||||
.values;
|
||||
ensure!(!email_values.is_empty(), "Email is required");
|
||||
ensure!(email_values.len() == 1, "Multiple emails are not supported");
|
||||
ensure!(validate_email(&email_values[0]), "Email is not valid");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct IsAdmin(pub bool);
|
||||
pub struct EmailIsRequired(pub bool);
|
||||
|
||||
pub fn read_all_form_attributes(
|
||||
schema: impl IntoIterator<Item = impl Into<GraphQlAttributeSchema>>,
|
||||
form_ref: &NodeRef,
|
||||
is_admin: IsAdmin,
|
||||
email_is_required: EmailIsRequired,
|
||||
) -> Result<Vec<AttributeValue>> {
|
||||
let form = form_ref.cast::<HtmlFormElement>().unwrap();
|
||||
let form_data = FormData::new_with_form(&form)
|
||||
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
||||
let all_values = schema
|
||||
.into_iter()
|
||||
.map(Into::<GraphQlAttributeSchema>::into)
|
||||
.filter(|attr| !attr.is_readonly && (is_admin.0 || attr.is_editable))
|
||||
.map(|attr| -> Result<AttributeValue> {
|
||||
let val = form_data
|
||||
.get_all(attr.name.as_str())
|
||||
.iter()
|
||||
.map(|js_val| js_val.as_string().unwrap_or_default())
|
||||
.filter(|val| !val.is_empty())
|
||||
.collect::<Vec<String>>();
|
||||
ensure!(
|
||||
val.len() <= 1 || attr.is_list,
|
||||
"Multiple values supplied for non-list attribute {}",
|
||||
attr.name
|
||||
);
|
||||
Ok(AttributeValue {
|
||||
name: attr.name.clone(),
|
||||
values: val,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if email_is_required.0 {
|
||||
validate_email_attributes(&all_values)?;
|
||||
}
|
||||
Ok(all_values)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use crate::infra::api::HostService;
|
||||
use anyhow::Result;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::{UseStateHandle, use_effect_with_deps, use_state_eq};
|
||||
|
||||
// Enum to represent a result that is fetched asynchronously.
|
||||
#[derive(Debug)]
|
||||
pub enum LoadableResult<T> {
|
||||
// The result is still being fetched
|
||||
Loading,
|
||||
// The async call is completed
|
||||
Loaded(Result<T>),
|
||||
}
|
||||
|
||||
impl<T: PartialEq> PartialEq for LoadableResult<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(LoadableResult::Loading, LoadableResult::Loading) => true,
|
||||
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
|
||||
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
|
||||
e1.to_string().eq(&e2.to_string())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_graphql_call<QueryType>(
|
||||
variables: QueryType::Variables,
|
||||
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
||||
where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
|
||||
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
||||
{
|
||||
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||
use_state_eq(|| LoadableResult::Loading);
|
||||
{
|
||||
let loadable_result = loadable_result.clone();
|
||||
use_effect_with_deps(
|
||||
move |variables| {
|
||||
let task = HostService::graphql_query::<QueryType>(
|
||||
variables.clone(),
|
||||
"Failed graphql query",
|
||||
);
|
||||
|
||||
spawn_local(async move {
|
||||
let response = task.await;
|
||||
loadable_result.set(LoadableResult::Loaded(response));
|
||||
});
|
||||
|
||||
|| ()
|
||||
},
|
||||
variables,
|
||||
)
|
||||
}
|
||||
loadable_result.clone()
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
pub mod api;
|
||||
pub mod attributes;
|
||||
pub mod common_component;
|
||||
pub mod cookies;
|
||||
pub mod form_utils;
|
||||
pub mod functional;
|
||||
pub mod graphql;
|
||||
pub mod modal;
|
||||
pub mod schema;
|
||||
pub mod tooltip;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::empty_docs)]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
use derive_more::Display;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::EnumString;
|
||||
use validator::ValidationError;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, EnumString, Display)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[strum(ascii_case_insensitive)]
|
||||
pub(crate) enum AttributeType {
|
||||
String,
|
||||
Integer,
|
||||
#[strum(serialize = "DATE_TIME", serialize = "DATETIME")]
|
||||
DateTime,
|
||||
#[strum(serialize = "JPEG_PHOTO", serialize = "JPEGPHOTO")]
|
||||
JpegPhoto,
|
||||
}
|
||||
|
||||
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
||||
AttributeType::try_from(attribute_type)
|
||||
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_attribute_type() {
|
||||
let attr_type: AttributeType = "STRING".try_into().unwrap();
|
||||
assert_eq!(attr_type, AttributeType::String);
|
||||
|
||||
let attr_type: AttributeType = "Integer".try_into().unwrap();
|
||||
assert_eq!(attr_type, AttributeType::Integer);
|
||||
|
||||
let attr_type: AttributeType = "DATE_TIME".try_into().unwrap();
|
||||
assert_eq!(attr_type, AttributeType::DateTime);
|
||||
|
||||
let attr_type: AttributeType = "JpegPhoto".try_into().unwrap();
|
||||
assert_eq!(attr_type, AttributeType::JpegPhoto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#![allow(clippy::empty_docs)]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||
pub type Tooltip;
|
||||
|
||||
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||
pub fn new(e: web_sys::Element) -> Tooltip;
|
||||
}
|
||||
+2
-1
@@ -2,11 +2,12 @@
|
||||
#![forbid(non_ascii_idents)]
|
||||
#![allow(clippy::uninlined_format_args)]
|
||||
#![allow(clippy::let_unit_value)]
|
||||
#![allow(clippy::unnecessary_operation)] // Doesn't work well with the html macro.
|
||||
|
||||
pub mod components;
|
||||
pub mod infra;
|
||||
|
||||
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||
use wasm_bindgen::prelude::{JsValue, wasm_bindgen};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn run_app() -> Result<(), JsValue> {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
[package]
|
||||
name = "lldap_auth"
|
||||
version = "0.3.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = ["opaque_server", "opaque_client"]
|
||||
opaque_server = []
|
||||
opaque_client = []
|
||||
js = []
|
||||
|
||||
[dependencies]
|
||||
rust-argon2 = "0.8"
|
||||
curve25519-dalek = "3"
|
||||
digest = "0.9"
|
||||
generic-array = "*"
|
||||
rand = "0.8"
|
||||
serde = "*"
|
||||
sha2 = "0.9"
|
||||
thiserror = "*"
|
||||
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.6"
|
||||
|
||||
[dependencies.chrono]
|
||||
version = "*"
|
||||
features = [ "serde" ]
|
||||
|
||||
# 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"
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "lldap_access_control"
|
||||
version = "0.1.0"
|
||||
description = "Access control wrappers for LLDAP"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tracing = "*"
|
||||
async-trait = "0.1"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.lldap_domain]
|
||||
path = "../domain"
|
||||
|
||||
[dependencies.lldap_domain_handlers]
|
||||
path = "../domain-handlers"
|
||||
|
||||
[dependencies.lldap_domain_model]
|
||||
path = "../domain-model"
|
||||
@@ -1,77 +1,31 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use lldap_auth::access_control::{Permission, ValidationResults};
|
||||
use lldap_domain::{
|
||||
public_schema::PublicSchema,
|
||||
requests::{
|
||||
CreateAttributeRequest, CreateGroupRequest, CreateUserRequest, UpdateGroupRequest,
|
||||
UpdateUserRequest,
|
||||
},
|
||||
schema::{AttributeSchema, Schema},
|
||||
types::{
|
||||
AttributeName, Group, GroupDetails, GroupId, GroupName, LdapObjectClass, User,
|
||||
UserAndGroups, UserId,
|
||||
},
|
||||
};
|
||||
use lldap_domain_handlers::handler::{
|
||||
BackendHandler, GroupBackendHandler, GroupListerBackendHandler, GroupRequestFilter,
|
||||
ReadSchemaBackendHandler, SchemaBackendHandler, UserBackendHandler, UserListerBackendHandler,
|
||||
UserRequestFilter,
|
||||
};
|
||||
use lldap_domain_model::error::Result;
|
||||
use std::collections::HashSet;
|
||||
use tracing::info;
|
||||
|
||||
use crate::domain::{
|
||||
error::Result,
|
||||
handler::{
|
||||
BackendHandler, CreateUserRequest, GroupListerBackendHandler, GroupRequestFilter,
|
||||
UpdateGroupRequest, UpdateUserRequest, UserListerBackendHandler, UserRequestFilter,
|
||||
},
|
||||
types::{Group, GroupDetails, GroupId, User, UserAndGroups, UserId},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Permission {
|
||||
Admin,
|
||||
PasswordManager,
|
||||
Readonly,
|
||||
Regular,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidationResults {
|
||||
pub user: UserId,
|
||||
pub permission: Permission,
|
||||
}
|
||||
|
||||
impl ValidationResults {
|
||||
#[cfg(test)]
|
||||
pub fn admin() -> Self {
|
||||
Self {
|
||||
user: UserId::new("admin"),
|
||||
permission: Permission::Admin,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_read_all(&self) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
|| self.permission == Permission::Readonly
|
||||
|| self.permission == Permission::PasswordManager
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_read(&self, user: &UserId) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
|| self.permission == Permission::PasswordManager
|
||||
|| self.permission == Permission::Readonly
|
||||
|| &self.user == user
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|
||||
|| &self.user == user
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_write(&self, user: &UserId) -> bool {
|
||||
self.permission == Permission::Admin || &self.user == user
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserReadableBackendHandler {
|
||||
pub trait UserReadableBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
|
||||
async fn get_schema(&self) -> Result<PublicSchema>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -92,24 +46,40 @@ pub trait UserWriteableBackendHandler: UserReadableBackendHandler {
|
||||
|
||||
#[async_trait]
|
||||
pub trait AdminBackendHandler:
|
||||
UserWriteableBackendHandler + ReadonlyBackendHandler + UserWriteableBackendHandler
|
||||
UserWriteableBackendHandler
|
||||
+ ReadonlyBackendHandler
|
||||
+ UserWriteableBackendHandler
|
||||
+ SchemaBackendHandler
|
||||
{
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
|
||||
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn create_group(&self, request: CreateGroupRequest) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
async fn add_user_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
||||
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
||||
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Handler: BackendHandler> UserReadableBackendHandler for Handler {
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User> {
|
||||
self.get_user_details(user_id).await
|
||||
<Handler as UserBackendHandler>::get_user_details(self, user_id).await
|
||||
}
|
||||
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>> {
|
||||
self.get_user_groups(user_id).await
|
||||
<Handler as UserBackendHandler>::get_user_groups(self, user_id).await
|
||||
}
|
||||
async fn get_schema(&self) -> Result<PublicSchema> {
|
||||
Ok(PublicSchema::from(
|
||||
<Handler as ReadSchemaBackendHandler>::get_schema(self).await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,44 +90,68 @@ impl<Handler: BackendHandler> ReadonlyBackendHandler for Handler {
|
||||
filters: Option<UserRequestFilter>,
|
||||
get_groups: bool,
|
||||
) -> Result<Vec<UserAndGroups>> {
|
||||
self.list_users(filters, get_groups).await
|
||||
<Handler as UserListerBackendHandler>::list_users(self, filters, get_groups).await
|
||||
}
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
||||
self.list_groups(filters).await
|
||||
<Handler as GroupListerBackendHandler>::list_groups(self, filters).await
|
||||
}
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails> {
|
||||
self.get_group_details(group_id).await
|
||||
<Handler as GroupBackendHandler>::get_group_details(self, group_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Handler: BackendHandler> UserWriteableBackendHandler for Handler {
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()> {
|
||||
self.update_user(request).await
|
||||
<Handler as UserBackendHandler>::update_user(self, request).await
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl<Handler: BackendHandler> AdminBackendHandler for Handler {
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()> {
|
||||
self.create_user(request).await
|
||||
<Handler as UserBackendHandler>::create_user(self, request).await
|
||||
}
|
||||
async fn delete_user(&self, user_id: &UserId) -> Result<()> {
|
||||
self.delete_user(user_id).await
|
||||
<Handler as UserBackendHandler>::delete_user(self, user_id).await
|
||||
}
|
||||
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
|
||||
self.add_user_to_group(user_id, group_id).await
|
||||
<Handler as UserBackendHandler>::add_user_to_group(self, user_id, group_id).await
|
||||
}
|
||||
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
|
||||
self.remove_user_from_group(user_id, group_id).await
|
||||
<Handler as UserBackendHandler>::remove_user_from_group(self, user_id, group_id).await
|
||||
}
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()> {
|
||||
self.update_group(request).await
|
||||
<Handler as GroupBackendHandler>::update_group(self, request).await
|
||||
}
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId> {
|
||||
self.create_group(group_name).await
|
||||
async fn create_group(&self, request: CreateGroupRequest) -> Result<GroupId> {
|
||||
<Handler as GroupBackendHandler>::create_group(self, request).await
|
||||
}
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()> {
|
||||
self.delete_group(group_id).await
|
||||
<Handler as GroupBackendHandler>::delete_group(self, group_id).await
|
||||
}
|
||||
async fn add_user_attribute(&self, request: CreateAttributeRequest) -> Result<()> {
|
||||
<Handler as SchemaBackendHandler>::add_user_attribute(self, request).await
|
||||
}
|
||||
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()> {
|
||||
<Handler as SchemaBackendHandler>::add_group_attribute(self, request).await
|
||||
}
|
||||
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()> {
|
||||
<Handler as SchemaBackendHandler>::delete_user_attribute(self, name).await
|
||||
}
|
||||
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()> {
|
||||
<Handler as SchemaBackendHandler>::delete_group_attribute(self, name).await
|
||||
}
|
||||
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||
<Handler as SchemaBackendHandler>::add_user_object_class(self, name).await
|
||||
}
|
||||
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||
<Handler as SchemaBackendHandler>::add_group_object_class(self, name).await
|
||||
}
|
||||
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||
<Handler as SchemaBackendHandler>::delete_user_object_class(self, name).await
|
||||
}
|
||||
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||
<Handler as SchemaBackendHandler>::delete_group_object_class(self, name).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,17 +178,24 @@ impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
|
||||
Self { handler }
|
||||
}
|
||||
|
||||
pub fn get_schema_only_handler(
|
||||
&self,
|
||||
_validation_result: &ValidationResults,
|
||||
) -> Option<&impl ReadSchemaBackendHandler> {
|
||||
Some(&self.handler)
|
||||
}
|
||||
|
||||
pub fn get_admin_handler(
|
||||
&self,
|
||||
validation_result: &ValidationResults,
|
||||
) -> Option<&impl AdminBackendHandler> {
|
||||
) -> Option<&(impl AdminBackendHandler + use<Handler>)> {
|
||||
validation_result.is_admin().then_some(&self.handler)
|
||||
}
|
||||
|
||||
pub fn get_readonly_handler(
|
||||
&self,
|
||||
validation_result: &ValidationResults,
|
||||
) -> Option<&impl ReadonlyBackendHandler> {
|
||||
) -> Option<&(impl ReadonlyBackendHandler + use<Handler>)> {
|
||||
validation_result.can_read_all().then_some(&self.handler)
|
||||
}
|
||||
|
||||
@@ -202,7 +203,7 @@ impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
|
||||
&self,
|
||||
validation_result: &ValidationResults,
|
||||
user_id: &UserId,
|
||||
) -> Option<&impl UserWriteableBackendHandler> {
|
||||
) -> Option<&(impl UserWriteableBackendHandler + use<Handler>)> {
|
||||
validation_result
|
||||
.can_write(user_id)
|
||||
.then_some(&self.handler)
|
||||
@@ -212,7 +213,7 @@ impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
|
||||
&self,
|
||||
validation_result: &ValidationResults,
|
||||
user_id: &UserId,
|
||||
) -> Option<&impl UserReadableBackendHandler> {
|
||||
) -> Option<&(impl UserReadableBackendHandler + use<Handler>)> {
|
||||
validation_result.can_read(user_id).then_some(&self.handler)
|
||||
}
|
||||
|
||||
@@ -236,19 +237,23 @@ impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
|
||||
Ok(self.get_permissions_from_groups(user_id, user_groups.iter().map(|g| &g.display_name)))
|
||||
}
|
||||
|
||||
pub fn get_permissions_from_groups<'a, Groups: Iterator<Item = &'a String> + Clone + 'a>(
|
||||
pub fn get_permissions_from_groups<Groups, T>(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
groups: Groups,
|
||||
) -> ValidationResults {
|
||||
let is_in_group = |name| groups.clone().any(|g| g == name);
|
||||
) -> ValidationResults
|
||||
where
|
||||
Groups: Iterator<Item = T> + Clone,
|
||||
T: AsRef<GroupName>,
|
||||
{
|
||||
let is_in_group = |name: GroupName| groups.clone().any(|g| *g.as_ref() == name);
|
||||
ValidationResults {
|
||||
user: user_id,
|
||||
permission: if is_in_group("lldap_admin") {
|
||||
permission: if is_in_group("lldap_admin".into()) {
|
||||
Permission::Admin
|
||||
} else if is_in_group("lldap_password_manager") {
|
||||
} else if is_in_group("lldap_password_manager".into()) {
|
||||
Permission::PasswordManager
|
||||
} else if is_in_group("lldap_strict_readonly") {
|
||||
} else if is_in_group("lldap_strict_readonly".into()) {
|
||||
Permission::Readonly
|
||||
} else {
|
||||
Permission::Regular
|
||||
@@ -259,12 +264,29 @@ impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
|
||||
|
||||
pub struct UserRestrictedListerBackendHandler<'a, Handler> {
|
||||
handler: &'a Handler,
|
||||
pub user_filter: Option<UserId>,
|
||||
user_filter: Option<UserId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<'a, Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
|
||||
for UserRestrictedListerBackendHandler<'a, Handler>
|
||||
impl<Handler: ReadSchemaBackendHandler + Sync> ReadSchemaBackendHandler
|
||||
for UserRestrictedListerBackendHandler<'_, Handler>
|
||||
{
|
||||
async fn get_schema(&self) -> Result<Schema> {
|
||||
let mut schema = self.handler.get_schema().await?;
|
||||
if self.user_filter.is_some() {
|
||||
let filter_attributes = |attributes: &mut Vec<AttributeSchema>| {
|
||||
attributes.retain(|a| a.is_visible);
|
||||
};
|
||||
filter_attributes(&mut schema.user_attributes.attributes);
|
||||
filter_attributes(&mut schema.group_attributes.attributes);
|
||||
}
|
||||
Ok(schema)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
|
||||
for UserRestrictedListerBackendHandler<'_, Handler>
|
||||
{
|
||||
async fn list_users(
|
||||
&self,
|
||||
@@ -286,8 +308,8 @@ impl<'a, Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<'a, Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
|
||||
for UserRestrictedListerBackendHandler<'a, Handler>
|
||||
impl<Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
|
||||
for UserRestrictedListerBackendHandler<'_, Handler>
|
||||
{
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
||||
let group_filter = self
|
||||
@@ -308,10 +330,14 @@ impl<'a, Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
|
||||
pub trait UserAndGroupListerBackendHandler:
|
||||
UserListerBackendHandler + GroupListerBackendHandler
|
||||
{
|
||||
fn user_filter(&self) -> &Option<UserId>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<'a, Handler: GroupListerBackendHandler + UserListerBackendHandler + Sync>
|
||||
UserAndGroupListerBackendHandler for UserRestrictedListerBackendHandler<'a, Handler>
|
||||
impl<Handler: GroupListerBackendHandler + UserListerBackendHandler + Sync>
|
||||
UserAndGroupListerBackendHandler for UserRestrictedListerBackendHandler<'_, Handler>
|
||||
{
|
||||
fn user_filter(&self) -> &Option<UserId> {
|
||||
&self.user_filter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "lldap_auth"
|
||||
version = "0.6.0"
|
||||
description = "Authentication protocol for LLDAP"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["opaque_server", "opaque_client"]
|
||||
opaque_server = []
|
||||
opaque_client = []
|
||||
js = []
|
||||
sea_orm = ["dep:sea-orm"]
|
||||
test = []
|
||||
|
||||
[dependencies]
|
||||
rust-argon2 = "2"
|
||||
curve25519-dalek = "3"
|
||||
digest = "0.9"
|
||||
generic-array = "0.14"
|
||||
rand = "0.8"
|
||||
sha2 = "0.9"
|
||||
thiserror = "2"
|
||||
uuid = { version = "1.18.1", features = ["serde"] }
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.7"
|
||||
|
||||
[dependencies.chrono]
|
||||
version = "*"
|
||||
features = ["serde"]
|
||||
|
||||
[dependencies.sea-orm]
|
||||
workspace = true
|
||||
features = ["macros"]
|
||||
optional = true
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
# For WASM targets, use the JS getrandom.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
|
||||
version = "0.2"
|
||||
features = ["js"]
|
||||
@@ -0,0 +1,58 @@
|
||||
use crate::types::UserId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
pub enum Permission {
|
||||
Admin,
|
||||
PasswordManager,
|
||||
Readonly,
|
||||
Regular,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ValidationResults {
|
||||
pub user: UserId,
|
||||
pub permission: Permission,
|
||||
}
|
||||
|
||||
impl ValidationResults {
|
||||
#[cfg(feature = "test")]
|
||||
pub fn admin() -> Self {
|
||||
Self {
|
||||
user: UserId::new("admin"),
|
||||
permission: Permission::Admin,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_read_all(&self) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
|| self.permission == Permission::Readonly
|
||||
|| self.permission == Permission::PasswordManager
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_read(&self, user: &UserId) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
|| self.permission == Permission::PasswordManager
|
||||
|| self.permission == Permission::Readonly
|
||||
|| &self.user == user
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|
||||
|| &self.user == user
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_write(&self, user: &UserId) -> bool {
|
||||
self.permission == Permission::Admin || &self.user == user
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,24 @@ use chrono::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod access_control;
|
||||
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 +41,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 +65,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,10 +106,114 @@ pub mod password_reset {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod types {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "sea_orm")]
|
||||
use sea_orm::{DbErr, DeriveValueType, TryFromU64, Value};
|
||||
|
||||
#[derive(
|
||||
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||
#[serde(from = "String")]
|
||||
pub struct CaseInsensitiveString(String);
|
||||
|
||||
impl CaseInsensitiveString {
|
||||
pub fn new(s: &str) -> Self {
|
||||
Self(s.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for CaseInsensitiveString {
|
||||
fn from(mut s: String) -> Self {
|
||||
s.make_ascii_lowercase();
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&String> for CaseInsensitiveString {
|
||||
fn from(s: &String) -> Self {
|
||||
Self::new(s.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for CaseInsensitiveString {
|
||||
fn from(s: &str) -> Self {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Clone,
|
||||
Default,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
derive_more::Debug,
|
||||
derive_more::Display,
|
||||
)]
|
||||
#[cfg_attr(feature = "sea_orm", derive(DeriveValueType))]
|
||||
#[serde(from = "CaseInsensitiveString")]
|
||||
#[debug(r#""{}""#, _0.as_str())]
|
||||
#[display("{}", _0.as_str())]
|
||||
pub struct UserId(CaseInsensitiveString);
|
||||
|
||||
impl UserId {
|
||||
pub fn new(s: &str) -> Self {
|
||||
s.into()
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
pub fn into_string(self) -> String {
|
||||
self.0.into_string()
|
||||
}
|
||||
}
|
||||
impl<T> From<T> for UserId
|
||||
where
|
||||
T: Into<CaseInsensitiveString>,
|
||||
{
|
||||
fn from(s: T) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sea_orm")]
|
||||
impl From<&UserId> for Value {
|
||||
fn from(user_id: &UserId) -> Self {
|
||||
user_id.as_str().into()
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "sea_orm")]
|
||||
impl TryFromU64 for UserId {
|
||||
fn try_from_u64(_n: u64) -> Result<Self, DbErr> {
|
||||
Err(DbErr::ConvertFromU64(
|
||||
"UserId cannot be constructed from u64",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct JWTClaims {
|
||||
#[serde(with = "chrono::serde::ts_seconds")]
|
||||
pub exp: DateTime<Utc>,
|
||||
#[serde(with = "chrono::serde::ts_seconds")]
|
||||
pub iat: DateTime<Utc>,
|
||||
pub jti: Uuid,
|
||||
pub user: String,
|
||||
pub groups: HashSet<String>,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::types::UserId;
|
||||
use opaque_ke::ciphersuite::CipherSuite;
|
||||
use rand::{CryptoRng, RngCore};
|
||||
|
||||
@@ -31,7 +32,6 @@ impl ArgonHasher {
|
||||
lanes: 1,
|
||||
mem_cost: 50 * 1024, // 50 MB, in KB
|
||||
secret: &[],
|
||||
thread_mode: argon2::ThreadMode::Sequential,
|
||||
time_cost: 1,
|
||||
variant: argon2::Variant::Argon2id,
|
||||
version: argon2::Version::Version13,
|
||||
@@ -77,10 +77,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.
|
||||
@@ -132,6 +132,12 @@ pub mod server {
|
||||
pub use super::*;
|
||||
pub type ServerRegistration = opaque_ke::ServerRegistration<DefaultSuite>;
|
||||
pub type ServerSetup = opaque_ke::ServerSetup<DefaultSuite>;
|
||||
|
||||
pub fn generate_random_private_key() -> ServerSetup {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
ServerSetup::new(&mut rng)
|
||||
}
|
||||
|
||||
/// Methods to register a new user, from the server side.
|
||||
pub mod registration {
|
||||
pub use super::*;
|
||||
@@ -145,12 +151,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 +184,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(),
|
||||
)?)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
[package]
|
||||
name = "lldap_domain_handlers"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
test = []
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
base64 = "0.21"
|
||||
ldap3_proto = "0.6.0"
|
||||
serde_bytes = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display", "from", "from_str"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.lldap_domain]
|
||||
path = "../domain"
|
||||
|
||||
[dependencies.lldap_domain_model]
|
||||
path = "../domain-model"
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
[dependencies.uuid]
|
||||
features = ["v1", "v3"]
|
||||
version = "1"
|
||||
@@ -1,10 +1,17 @@
|
||||
use super::{
|
||||
error::Result,
|
||||
use async_trait::async_trait;
|
||||
use ldap3_proto::proto::LdapSubstringFilter;
|
||||
use lldap_domain::{
|
||||
requests::{
|
||||
CreateAttributeRequest, CreateGroupRequest, CreateUserRequest, UpdateGroupRequest,
|
||||
UpdateUserRequest,
|
||||
},
|
||||
schema::Schema,
|
||||
types::{
|
||||
Group, GroupDetails, GroupId, JpegPhoto, User, UserAndGroups, UserColumn, UserId, Uuid,
|
||||
AttributeName, AttributeValue, Group, GroupDetails, GroupId, GroupName, LdapObjectClass,
|
||||
User, UserAndGroups, UserId, Uuid,
|
||||
},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use lldap_domain_model::{error::Result, model::UserColumn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -45,102 +52,90 @@ impl SubStringFilter {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LdapSubstringFilter> for SubStringFilter {
|
||||
fn from(
|
||||
LdapSubstringFilter {
|
||||
initial,
|
||||
any,
|
||||
final_,
|
||||
}: LdapSubstringFilter,
|
||||
) -> Self {
|
||||
Self {
|
||||
initial,
|
||||
any,
|
||||
final_,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum UserRequestFilter {
|
||||
True,
|
||||
False,
|
||||
And(Vec<UserRequestFilter>),
|
||||
Or(Vec<UserRequestFilter>),
|
||||
Not(Box<UserRequestFilter>),
|
||||
UserId(UserId),
|
||||
UserIdSubString(SubStringFilter),
|
||||
Equality(UserColumn, String),
|
||||
AttributeEquality(AttributeName, AttributeValue),
|
||||
SubString(UserColumn, SubStringFilter),
|
||||
// Check if a user belongs to a group identified by name.
|
||||
MemberOf(String),
|
||||
MemberOf(GroupName),
|
||||
// Same, by id.
|
||||
MemberOfId(GroupId),
|
||||
CustomAttributePresent(AttributeName),
|
||||
}
|
||||
|
||||
impl From<bool> for UserRequestFilter {
|
||||
fn from(val: bool) -> Self {
|
||||
if val {
|
||||
Self::And(vec![])
|
||||
} else {
|
||||
Self::Not(Box::new(Self::And(vec![])))
|
||||
}
|
||||
if val { Self::True } else { Self::False }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum GroupRequestFilter {
|
||||
True,
|
||||
False,
|
||||
And(Vec<GroupRequestFilter>),
|
||||
Or(Vec<GroupRequestFilter>),
|
||||
Not(Box<GroupRequestFilter>),
|
||||
DisplayName(String),
|
||||
DisplayName(GroupName),
|
||||
DisplayNameSubString(SubStringFilter),
|
||||
Uuid(Uuid),
|
||||
GroupId(GroupId),
|
||||
// Check if the group contains a user identified by uid.
|
||||
Member(UserId),
|
||||
AttributeEquality(AttributeName, AttributeValue),
|
||||
CustomAttributePresent(AttributeName),
|
||||
}
|
||||
|
||||
impl From<bool> for GroupRequestFilter {
|
||||
fn from(val: bool) -> Self {
|
||||
if val {
|
||||
Self::And(vec![])
|
||||
} else {
|
||||
Self::Not(Box::new(Self::And(vec![])))
|
||||
}
|
||||
if val { Self::True } else { Self::False }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct CreateUserRequest {
|
||||
// Same fields as User, but no creation_date, and with password.
|
||||
pub user_id: UserId,
|
||||
pub email: String,
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar: Option<JpegPhoto>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct UpdateUserRequest {
|
||||
// Same fields as CreateUserRequest, but no with an extra layer of Option.
|
||||
pub user_id: UserId,
|
||||
pub email: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar: Option<JpegPhoto>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UpdateGroupRequest {
|
||||
pub group_id: GroupId,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait LoginHandler: Send + Sync {
|
||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupListerBackendHandler {
|
||||
pub trait GroupListerBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupBackendHandler {
|
||||
pub trait GroupBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn create_group(&self, request: CreateGroupRequest) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserListerBackendHandler {
|
||||
pub trait UserListerBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn list_users(
|
||||
&self,
|
||||
filters: Option<UserRequestFilter>,
|
||||
@@ -149,7 +144,7 @@ pub trait UserListerBackendHandler {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserBackendHandler {
|
||||
pub trait UserBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
@@ -159,6 +154,25 @@ pub trait UserBackendHandler {
|
||||
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ReadSchemaBackendHandler {
|
||||
async fn get_schema(&self) -> Result<Schema>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SchemaBackendHandler: ReadSchemaBackendHandler {
|
||||
async fn add_user_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
||||
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
||||
// Note: It's up to the caller to make sure that the attribute is not hardcoded.
|
||||
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||
|
||||
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BackendHandler:
|
||||
Send
|
||||
@@ -167,53 +181,18 @@ pub trait BackendHandler:
|
||||
+ UserBackendHandler
|
||||
+ UserListerBackendHandler
|
||||
+ GroupListerBackendHandler
|
||||
+ ReadSchemaBackendHandler
|
||||
+ SchemaBackendHandler
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mockall::mock! {
|
||||
pub TestBackendHandler{}
|
||||
impl Clone for TestBackendHandler {
|
||||
fn clone(&self) -> Self;
|
||||
}
|
||||
#[async_trait]
|
||||
impl GroupListerBackendHandler for TestBackendHandler {
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||
}
|
||||
#[async_trait]
|
||||
impl GroupBackendHandler for TestBackendHandler {
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
}
|
||||
#[async_trait]
|
||||
impl UserListerBackendHandler for TestBackendHandler {
|
||||
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
|
||||
}
|
||||
#[async_trait]
|
||||
impl UserBackendHandler for TestBackendHandler {
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
|
||||
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
|
||||
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
}
|
||||
#[async_trait]
|
||||
impl BackendHandler for TestBackendHandler {}
|
||||
#[async_trait]
|
||||
impl LoginHandler for TestBackendHandler {
|
||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine;
|
||||
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use lldap_domain::types::JpegPhoto;
|
||||
use pretty_assertions::assert_ne;
|
||||
|
||||
#[test]
|
||||
fn test_uuid_time() {
|
||||
use chrono::prelude::*;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod handler;
|
||||
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "lldap_domain_model"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
homepage.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
test = []
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.21"
|
||||
bincode = "1.3"
|
||||
orion = "0.17"
|
||||
serde_bytes = "0.11"
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.derive_more]
|
||||
features = ["debug", "display", "from", "from_str"]
|
||||
default-features = false
|
||||
version = "1"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = ["opaque_server", "opaque_client", "sea_orm"]
|
||||
|
||||
[dependencies.lldap_domain]
|
||||
path = "../domain"
|
||||
|
||||
[dependencies.sea-orm]
|
||||
workspace = true
|
||||
features = ["macros"]
|
||||
|
||||
[dependencies.serde]
|
||||
workspace = true
|
||||
|
||||
[dependencies.uuid]
|
||||
features = ["v1", "v3"]
|
||||
version = "1"
|
||||
@@ -3,10 +3,12 @@ use thiserror::Error;
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DomainError {
|
||||
#[error("Authentication error: `{0}`")]
|
||||
#[error("Authentication error {0}")]
|
||||
AuthenticationError(String),
|
||||
#[error("Database error: `{0}`")]
|
||||
DatabaseError(#[from] sea_orm::DbErr),
|
||||
#[error("Database transaction error: `{0}`")]
|
||||
DatabaseTransactionError(#[from] sea_orm::TransactionError<sea_orm::DbErr>),
|
||||
#[error("Authentication protocol error for `{0}`")]
|
||||
AuthenticationProtocolError(#[from] lldap_auth::opaque::AuthenticationError),
|
||||
#[error("Unknown crypto error: `{0}`")]
|
||||
@@ -21,4 +23,13 @@ pub enum DomainError {
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
impl From<sea_orm::TransactionError<DomainError>> for DomainError {
|
||||
fn from(value: sea_orm::TransactionError<DomainError>) -> Self {
|
||||
match value {
|
||||
sea_orm::TransactionError::Connection(e) => e.into(),
|
||||
sea_orm::TransactionError::Transaction(e) => e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DomainError>;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod error;
|
||||
pub mod model;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user