119 Commits

Author SHA1 Message Date
Valentin Tolmer
acd39d20b1 release: 0.6.1 2024-11-22 22:47:49 +01:00
Valentin Tolmer
0ddeab8caa server: Fix schema migration from v8 for sqlite and postgres
Neither supports limits, but we can delete all the duplicate memberships and re-insert a single one
2024-11-21 23:34:37 +01:00
xeoneox
64514ddfc6 example_configs: expand url for OneDev config
fix capitalization and expound URL example
2024-11-21 10:01:24 +01:00
Valentin Tolmer
c47be779a3 docs: update architecture.md 2024-11-19 22:07:02 +01:00
xeoneox
fea2ed5b79 example_configs: Add onedev 2024-11-19 22:01:30 +01:00
Jan Düpmeier
e982908768 cargo,auth,server: update opaque-ke => 0.7 2024-11-17 13:34:01 +01:00
Valentin Tolmer
713dbde4cb server: Fix the instructions to silence the key_seed warning 2024-11-14 22:27:32 +01:00
Ansgar Tasler
579dd5e1b6 readme: add reference to terraform provider (#1035) 2024-11-13 16:04:41 +01:00
traverseda
3828ec7624 example_configs: Update pam example for release 0.6..0 2024-11-13 12:38:45 +01:00
Valentin Tolmer
b8c06ebd75 chore: bump version to 0.6.1-alpha 2024-11-09 22:25:13 +01:00
Valentin Tolmer
130d2552ac github: Remove release PR comment bot
It only runs for PRs that are mentioned in the release notes, but I only mention issues
2024-11-09 22:20:31 +01:00
Valentin Tolmer
098745ebc9 release: 0.6.0 2024-11-09 21:46:49 +01:00
Valentin Tolmer
95337e2cd8 server: Remove session-wide logging, add session_uuid to message logs 2024-11-04 21:47:26 +01:00
Valentin Tolmer
143eb70bee server: Only use a single connection with SQlite
Several writer connections can lock the DB and cause other inserts to fail.

A single connection should be enough given the usual workloads
2024-10-30 15:35:47 +01:00
Valentin Tolmer
35fe521cbe server: Correctly handle removal of the display_name attribute 2024-10-29 15:33:46 +01:00
Valentin Tolmer
c8601b9169 server: Correctly handle attempts to probe for password resets 2024-10-28 20:09:46 +01:00
Hobbabobba
8f6c324de7 example_configs: add ldap_ssl to vaultwarden_ldap_sync:2.0.2 (#1011) 2024-10-28 16:43:49 +01:00
Valentin Tolmer
f0fcc88f1d server: Fix env warning for nested keys 2024-10-28 16:23:25 +01:00
Valentin Tolmer
c08ddecd32 server: Fix missing lowercasing when changing passwords through LDAP 2024-10-28 16:06:25 +01:00
Valentin Tolmer
4ebfd0525b app: Allow custom attributes in group creation 2024-10-28 15:59:08 +01:00
Valentin Tolmer
a190fe7ddf server: return custom attributes when asked for all attributes 2024-10-26 19:07:08 +02:00
dependabot[bot]
df188ee83f build(deps): bump actions/checkout from 4.2.1 to 4.2.2
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.1 to 4.2.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.2.1...v4.2.2)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-24 07:46:46 +02:00
Valentin Tolmer
52c917d967 server: improve key_seed warning 2024-10-22 00:48:40 +02:00
Valentin Tolmer
f01daae6a8 server: Fix env variable warning 2024-10-22 00:48:29 +02:00
Valentin Tolmer
62b2afa283 app: fix password reset probing
It was still using get, but should have used post
2024-10-22 00:38:09 +02:00
Valentin Tolmer
305b272cdf app: Add support for group attributes 2024-10-22 00:37:38 +02:00
Daniel S. Reichenbach
a95ac38083 example_configs: keycloak typo for first name attribute (#1004)
It should be `givenName` instead of `givenname`. Using the later one, will result in Keycloak bugging out during the sync process, and henceforth displaying an empty user list.
2024-10-18 12:52:42 +02:00
Valentin Tolmer
abfe2f3a17 cargo,app,auth: Update dependencies, fix breaks 2024-10-17 00:17:41 +02:00
Johannes Kastl
11d766b2ba Dockerfile: add jq/jo/curl, required by bootstrap.sh 2024-10-14 21:34:04 +02:00
Valentin Tolmer
56eee6908e server: Add a way to print raw logs
If the variable LLDAP_RAW_LOG is set, the logs will be both formatted with tracing_forest and printed raw
2024-10-10 21:27:36 +02:00
Grzegorz Godlewski
dcb45d4f6b Add support for bootstrapping schemas (#991)
* Moved default bootstrap dirs into single /bootstrap parent dir in order to have single docker volume bind (with fallback to previous folder hierarchy)
* Added default values for LDAP user and credentials
* Added support for bootstrapping schema

Place schema files under /bootstrap/(user|group)-schemas/*.json

Sample content:
[
  {
    "name" : "test_attrib",
    "attributeType" : "STRING",
    "isEditable" : true,
    "isList" : false,
    "isVisible" : true
  }
]
2024-10-10 21:05:01 +02:00
dependabot[bot]
a6eac55fc7 build(deps): bump actions/checkout from 4.1.7 to 4.2.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.7 to 4.2.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.1.7...v4.2.1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-08 00:03:48 +02:00
dependabot[bot]
1c6646d8c5 build(deps): bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-07 23:36:28 +02:00
Blueswen
362e968e00 example_configs: Update base DN in grafana_ldap_config.toml
Use `dc=com` as the same as the default DN.
2024-10-07 18:24:33 +02:00
Valentin Tolmer
17bcd7645b app: Clean up code, don't error on admin empty email 2024-10-05 23:10:40 +02:00
Austin Alvarado
dcba3d17dc app: Add support for user-created attributes
Note: This PR doesn't handle errors around Jpeg files very well.

Co-authored-by: Bojidar Marinov <bojidar.marinov.bg@gmail.com>
Co-authored-by: Austin Alvarado <pixelrazor@gmail.com>
2024-09-30 23:53:14 +02:00
Valentin Tolmer
1f3f73585b server: Add logging for password resets, add name for successful opaque logins 2024-09-26 22:51:34 +02:00
Valentin Tolmer
0c6a92a8fa server: Clarify logging of login attempts and failures 2024-09-26 20:43:19 +02:00
PopeRigby
120ad34f92 example_configs: Update Radicale guide with configuration for Radicale 3.3.0 (#979) 2024-09-22 21:58:53 +02:00
Roman
a2ba71ac19 example_configs: Update PAM integration
* Add more information for PAM integration:

* Add info that custom attributes only work on nightly
* Add sample lldap-cli command to set attribute

* Modify nslcd to use unix-uid/gid directly as it is now supported

* Add readme for PAM integration, removing the need for is-unix-user/group.
2024-09-17 00:19:03 +02:00
Valentin Tolmer
10a820f2a2 server: detect anonymous binds and return a correct error 2024-09-11 22:19:58 +02:00
Valentin Tolmer
01f97f5ed4 server: clean up the expected keys 2024-09-10 23:25:33 +02:00
Valentin Tolmer
f14aa2284c server: Detect unknown env variables (e.g. due to typos) 2024-09-08 21:45:36 +02:00
Valentin Tolmer
65e2103365 server: Simplify the debug print of various structs
And use derive_more more liberally to simplify the impls
2024-09-08 00:43:58 +02:00
Valentin Tolmer
5db0072cfa server: clarify SMTP error message
SMTP docs for many email providers use SSL to mean SSL/TLS, and TLS to mean STARTTLS, causing endless confusion. This should hopefully help.
2024-09-07 23:50:43 +02:00
Valentin Tolmer
1d8d3eb73f server: Fix attribute name 2024-09-07 22:27:20 +02:00
Joshua M. Clulow
97e4d90eb7 dependencies: update whoami to fix illumos build 2024-09-02 21:11:58 +02:00
Valentin Tolmer
6cf0f6df06 server: map email and display_name from attributes into user fields 2024-08-28 00:25:23 +02:00
Valentin Tolmer
b1384818d2 server: Add a is_readonly attribute to the schema 2024-08-27 23:04:24 +02:00
Valentin Tolmer
3ec44a58be server: Allow password reset every time the server starts 2024-08-26 12:53:25 +02:00
aokblast
6f7bfca682 Use sysrc in FreeBSD install instruction 2024-08-25 08:12:02 +02:00
Valentin Tolmer
2c79a40a73 server: Mask the details of SMTP errors, sleep when failing to send an email 2024-08-21 16:19:13 +02:00
Valentin Tolmer
25c6d6c962 README: fix anchor link 2024-08-19 22:42:18 +02:00
Valentin Tolmer
04b048dd47 example_configs: add PAM configuration guide 2024-08-19 22:38:58 +02:00
Valentin Tolmer
dc26f97117 server: Fix compilation on Windows 2024-08-18 20:12:03 +02:00
Valentin Tolmer
09c5d9f925 server: Fix implementation of attribute present filter
Instead of just doing a schema check, this actually looks for users that have a value for this attribute.
2024-08-16 23:56:02 +02:00
Valentin Tolmer
ee7f9c9f41 server: Update ldap3_proto dependency 2024-08-16 23:47:06 +02:00
Valentin Tolmer
fa9c503de7 server: Add support for memberOf with plain user names, relax hard errors
This should help when the client sends some invalid-looking queries as part of a bigger filter
2024-08-16 23:21:20 +02:00
Masgalor
4138963bee readme: Improve the package repository section 2024-08-09 00:27:54 +02:00
Alyssa Ross
5a2a92bbda cargo: update time
Fixes building with Rust 1.80.0.

Closes: https://github.com/lldap/lldap/issues/945
2024-08-08 22:39:10 +02:00
Dakota G
6aa9303339 example_configs: Add configuration for Netbox 2024-08-06 15:06:16 +02:00
Bojidar Marinov
049a360506 server: Lookup first_name/last_name in the right list of attributes (#943)
Note the std::mem::take(&mut user.attributes) further up that zeroes out user.attributes
2024-07-31 23:55:07 +02:00
jakob42
b26de34e0d example_configs: add Prosody 2024-07-31 07:10:38 +02:00
Josh Thorpe
15c28110b5 example_configs: clean up jellyfin.md
Restructured to match the jellyfin plugin UI.
2024-07-24 14:46:40 +02:00
ChevySSinSD
83508a363c generate_secrets: improve portability
Updated print_random function definition to be compatible with multiple default shells
2024-07-23 16:36:11 +02:00
fengxsong
010eec22d3 example_configs: fix dex integration
Host and optional port of the LDAP server are in the form "host:port".
2024-07-22 07:38:22 +02:00
Binh-Nguyen Tran
b33d56a459 bootstrap.sh: use exact match instead of substring when checking user id existence
Signed-off-by: Binh-Nguyen Tran <tbnguyen1407@gmail.com>
2024-07-20 11:07:51 +02:00
sean
6eb5b959bf example_config: adjusted addressand attributes for authelia 5.0.0 compliance 2024-07-10 22:00:35 +02:00
Valentin Tolmer
6f46ffd1e4 clippy: new fixes 2024-06-16 12:18:46 +02:00
Noah Snelson
73686224dd example_configs: Add Carpal (#916) 2024-06-15 22:39:42 +02:00
dependabot[bot]
56ed37ef8a build(deps): bump actions/checkout from 4.1.5 to 4.1.7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.5 to 4.1.7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.1.5...v4.1.7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-13 05:27:34 +02:00
thielj
39e1a02255 Update minio.md
The described configuration didn't work for me; I've added my working configuration at the bottom. Hope that helps someone!
2024-06-10 07:35:44 +02:00
dependabot[bot]
4f050cded5 build(deps): bump actions/checkout from 4.1.4 to 4.1.5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.4 to 4.1.5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.1.4...v4.1.5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 22:40:29 +02:00
RobertL
254a168e78 example_configs: mailserver: Include protocol in server host definition
Without the protocol specified, Mailserver throws an error
2024-05-03 09:32:54 +02:00
Pierre Penninckx
85b83aff5f example_configs: add user_id mapping for nextcloud
This allows both LDAP and SSO backends to have consistent usernames
2024-05-02 09:19:33 +02:00
lvillis
199a80ca5b example_configs: Add Metabase and sonarqube (#906) 2024-04-30 12:17:25 +02:00
Torstein Eide
f96868318a example_configs: pfsense.md, add warning about error about OU 2024-04-27 14:42:48 +02:00
dependabot[bot]
04b0fa0ae9 build(deps): bump actions/checkout from 4.1.3 to 4.1.4
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.3 to 4.1.4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.1.3...v4.1.4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-24 23:26:16 +02:00
dependabot[bot]
2e08c6a7ec build(deps): bump actions/checkout from 4.1.2 to 4.1.3
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.2 to 4.1.3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.1.2...v4.1.3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-23 07:22:01 +02:00
dependabot[bot]
892492815d build(deps): bump actions/checkout from 4.1.1 to 4.1.2
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.1 to 4.1.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.1.1...v4.1.2)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 21:52:51 +02:00
Valentin Tolmer
2597a250f0 github: Update codecove action to v4 2024-04-22 21:37:23 +02:00
Valentin Tolmer
f67f090bde migration_tool: fix clippy warning 2024-04-22 20:42:40 +02:00
n-connect
a97881477f example_configs: add FreeBSD rc.d service script
Use:
extract (the future) FreeBSD release package into /usr/local/ -> so your files will be under /usr/local/lldap_server/
save/copy this rc.d script file into /usr/local/etc/rc.d/
finally cat lldap_enable=YES >> /etc/rc.conf
the service script set to run the lldap server as "www" user - make sure the whole lldap_server directory is accessible/runnable by "www". Simplest to run chown -R www:www /usr/local/lldap_server
2024-04-22 17:39:05 +02:00
nitnelave
8587fc38fd config: Fix the admin reset password option in template 2024-04-04 00:35:40 +02:00
Jonathan
6d65a2546c docker: Include bootstrap.sh in main image 2024-04-01 18:54:09 +02:00
kri164
7806ed34ff example_configs: Update nextcloud.md - add a example of group filter (#882) 2024-03-28 10:29:55 +01:00
Valentin Tolmer
22623bfab1 server: Fix user search for multiple memberOf 2024-03-18 22:02:12 +01:00
lvillis
2f20f63b41 example_configs: Fix typo in nexus.md 2024-03-15 12:02:56 +01:00
lordratner
87d825626c example_configs: fix role in authelia 2024-03-14 20:42:03 +01:00
Aziz
8cbad6d5bd example_configs: Add MegaRAC-SP-X-BMC 2024-03-14 09:36:12 +01:00
kevin7s-io
8db7d8a46f example_configs: Add Harbor 2024-03-12 21:42:37 +01:00
Valentin Tolmer
533d1bcfd0 github: Update dev container to add FreeBSD target 2024-03-07 09:18:05 +01:00
Valentin Tolmer
3d8aafaa9d app: Improve the email reset message 2024-02-27 08:41:24 +01:00
Valentin Tolmer
f93681239b app: default to user_id if display_name is empty, when adding users to groups 2024-02-27 08:27:33 +01:00
Valentin Tolmer
13720c101c server: silence clippy warnings 2024-02-27 08:22:58 +01:00
Valentin Tolmer
a1eb708cf3 server: Add missing unique indices on lowercase email/group names, fix memberof lookup 2024-02-26 10:53:51 +01:00
Adam Shand
959bb907d8 example_configs: Add OCIS 2024-02-20 10:40:47 +01:00
jakob42
22074f56d2 mentioned dokuwiki authchained plugin 2024-02-12 09:24:14 +01:00
Valentin Tolmer
5c5b87d5af app,server: Switch /reset/step1 to a POST request
Otherwise, caching can become an issue. Also, it's not an idempotent request.
2024-02-09 00:20:31 +01:00
Valentin Tolmer
f65a6f524a app: Fix GetDetails rendering loop in avatar 2024-02-08 21:56:11 +01:00
Valentin Tolmer
96f5b31e0c server: Add graphQL methods to manage custom LDAP object classes 2024-02-06 22:39:05 +01:00
Valentin Tolmer
4955b7fac1 server: Add support for the custom LDAP object classes in LDAP filters 2024-02-06 22:39:05 +01:00
Valentin Tolmer
646fe32645 server: Add support for custom LDAP object classes for users and groups 2024-02-05 22:51:02 +01:00
Austin Alvarado
fa9743be6a app: create avatar component and reorganize a little bit (#830)
* Create avatar component and reorganize a little bit

* html fmt

* fmt
2024-02-05 07:55:49 -07:00
Valentin Tolmer
38c4296d62 github: Improve codecov integration with better config 2024-02-02 15:52:29 +01:00
Valentin Tolmer
1c65cd115e server: Fix panic due to database collation
When the database's collation is not "C", the DB order is not the same as the
Rust order. As such, asserting that the elements are in increasing order fails.
However, since both queries get the order from the database, they should be in
the same order.

With too many users, the query had a giant filter `IN (u1, u2, u3,
...)`. In PostgreSQL, we can pass the users as an array instead, but that
doesn't work with SQLite. Instead, we repeat the filter from the
previous query to get the same users/groups, as a subquery.
2024-02-02 15:39:16 +01:00
Austin Alvarado
8f2391a792 app: create group attribute schema page (#825) 2024-02-01 10:56:47 -07:00
shroomify-it
bb2654f9c2 example_configs: add radicale DAV server to the readme 2024-01-28 08:44:25 +01:00
shroomify-it
770e934859 example_configs: Create radicale.md 2024-01-28 08:42:19 +01:00
Austin Alvarado
cc0827f271 app: update forms to use new components (#818) 2024-01-27 09:10:02 -07:00
Austin Alvarado
93f3057b8f server: remove debug print 2024-01-25 22:35:42 +01:00
dependabot[bot]
206e98c986 build(deps): bump peter-evans/dockerhub-description from 3 to 4
Bumps [peter-evans/dockerhub-description](https://github.com/peter-evans/dockerhub-description) from 3 to 4.
- [Release notes](https://github.com/peter-evans/dockerhub-description/releases)
- [Commits](https://github.com/peter-evans/dockerhub-description/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peter-evans/dockerhub-description
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-25 21:34:56 +01:00
HighwayStar
28e6fa0f10 example_configs: Fix docker-mailserver example
* Fixes following issues:
 - double braces around mail= filter cause:
 ldap_search_ext: Bad search filter (-7)
 - too wide/upper level base DN cause, changed to ou= level helps
 result: 53 Server is unwilling to perform
 text: Unsupported group attribute for substring filter: "mail"
2024-01-24 08:11:15 +01:00
Valentin Tolmer
d4b3b4649e server: Clean up main, make more functions async 2024-01-24 00:04:43 +01:00
Austin Alvarado
b78e093205 app: add user attributes schema page (#802) 2024-01-22 21:53:33 -07:00
Valentin Tolmer
c2eed8909a server: Only call expand_attributes at most once per request 2024-01-23 00:17:08 +01:00
Valentin Tolmer
b82a2d5705 server: Treat the database password as a secret 2024-01-22 23:12:33 +01:00
Valentin Tolmer
addd453287 server: don't error on global searches if only one side fails 2024-01-22 22:30:54 +01:00
Valentin Tolmer
e308a5e9a1 server: Add the attribute schema to the attributes in graphql
And make sure that we only request the schema once per top-level query
2024-01-21 23:25:57 +01:00
135 changed files with 8224 additions and 3032 deletions

View File

@@ -1,4 +1,4 @@
FROM rust:1.72
FROM rust:1.74
ARG USERNAME=lldapdev
# We need to keep the user as 1001 to match the GitHub runner's UID.

11
.github/codecov.yml vendored
View File

@@ -1,10 +1,19 @@
codecov:
require_ci_to_pass: yes
comment:
layout: "diff,flags"
layout: "header,diff,files"
require_changes: true
require_base: true
require_head: true
coverage:
status:
project:
default:
target: "75%"
threshold: "0.1%"
removed_code_behavior: adjust_base
github_checks:
annotations: true
ignore:
- "app"
- "docs"

View File

@@ -59,12 +59,12 @@ RUN set -x \
&& 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
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 && \
RUN apk add --no-cache tini ca-certificates bash tzdata jq curl jo && \
addgroup -g $GID $USER && \
adduser \
--disabled-password \
@@ -80,5 +80,6 @@ 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"]

View File

@@ -65,7 +65,7 @@ ENV UID=1000
ENV GID=1000
ENV USER=lldap
RUN apt update && \
apt install -y --no-install-recommends tini openssl ca-certificates tzdata && \
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 && \
@@ -74,6 +74,7 @@ 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"]

View File

@@ -1,5 +1,5 @@
# Keep tracking base image
FROM rust:1.74-slim-bookworm
FROM rust:1.81-slim-bookworm
# Set needed env path
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
@@ -34,7 +34,8 @@ RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
### Add musl target
RUN rustup target add x86_64-unknown-linux-musl && \
rustup target add aarch64-unknown-linux-musl && \
rustup target add armv7-unknown-linux-musleabihf
rustup target add armv7-unknown-linux-musleabihf && \
rustup target add x86_64-unknown-freebsd
CMD ["bash"]

View File

@@ -39,7 +39,7 @@ 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:latest #
# lldap/rust-dev #
#######################################################################################
# Cargo build
### armv7, aarch64 and amd64 is musl based
@@ -84,10 +84,10 @@ jobs:
needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
container:
image: lldap/rust-dev:latest
image: lldap/rust-dev:v81
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.2.2
- uses: actions/cache@v4
with:
path: |
@@ -125,14 +125,14 @@ jobs:
matrix:
target: [armv7-unknown-linux-musleabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
container:
image: lldap/rust-dev:latest
image: lldap/rust-dev:v81
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.2.2
- uses: actions/cache@v4
with:
path: |
@@ -294,7 +294,7 @@ jobs:
steps:
- name: Checkout scripts
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.2.2
with:
sparse-checkout: 'scripts'
@@ -482,7 +482,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.2.2
- name: Download all artifacts
uses: actions/download-artifact@v4
@@ -512,7 +512,7 @@ jobs:
tags: ${{ matrix.container }}-base
- name: Build ${{ matrix.container }} Base Docker Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
# On PR will fail, force fully uncomment push: true, or docker image will fail for next steps
@@ -613,7 +613,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build ${{ matrix.container }}-rootless Docker Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -627,7 +627,7 @@ jobs:
### This docker build always the last, due :latest tag pushed multiple times, for whatever variants may added in future add docker build above this
- name: Build ${{ matrix.container }} Docker Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
@@ -641,7 +641,7 @@ jobs:
- 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 }}
@@ -649,7 +649,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 }}

View File

@@ -1,20 +0,0 @@
name: Release Bot
on:
release:
types: [published]
jobs:
comment:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: nflaig/release-comment-on-pr@master
with:
token: ${{ secrets.RELEASE_BOT_TOKEN }}
message: |
Thank you everyone for the contribution!
This feature is now available in the latest release, [${releaseTag}](${releaseUrl}).
You can support LLDAP by starring our repo, contributing some configuration examples and becoming a sponsor.

View File

@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.2.2
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --verbose --workspace
@@ -52,7 +52,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.2.2
- uses: Swatinem/rust-cache@v2
@@ -69,7 +69,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.2.2
- uses: Swatinem/rust-cache@v2
@@ -88,7 +88,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.2.2
- name: Install Rust
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
@@ -101,16 +101,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 }}

View File

@@ -5,6 +5,107 @@ 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.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
@@ -71,7 +172,7 @@ systems, including PAM authentication.
## [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`.

2346
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,9 +17,5 @@ lto = true
[profile.release.package.lldap_app]
opt-level = 's'
[patch.crates-io.opaque-ke]
git = 'https://github.com/nitnelave/opaque-ke/'
branch = 'zeroize_1.5'
[patch.crates-io.lber]
git = 'https://github.com/inejge/ldap3/'

View File

@@ -41,7 +41,7 @@ RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password
&& ./app/build.sh
# Final image
FROM alpine:3.16
FROM alpine:3.19
ENV GOSU_VERSION 1.14
# Fetch gosu from git
@@ -80,6 +80,7 @@ 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/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 \

277
README.md
View File

@@ -38,6 +38,7 @@
- [With Docker](#with-docker)
- [With Kubernetes](#with-kubernetes)
- [From a package repository](#from-a-package-repository)
- [With FreeBSD](#with-freebsd)
- [From source](#from-source)
- [Backend](#backend)
- [Frontend](#frontend)
@@ -47,6 +48,7 @@
- [Client configuration](#client-configuration)
- [Compatible services](#compatible-services)
- [General configuration guide](#general-configuration-guide)
- [Integration with OS's](#integration-with-oss)
- [Sample client configurations](#sample-client-configurations)
- [Incompatible services](#incompatible-services)
- [Migrating from SQLite](#migrating-from-sqlite)
@@ -161,6 +163,15 @@ services:
# You can also set a different database:
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
# If using SMTP, set the following variables
# - LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET=true
# - LLDAP_SMTP_OPTIONS__SERVER=smtp.example.com
# - LLDAP_SMTP_OPTIONS__PORT=465 # Check your smtp providor's documentation for this setting
# - LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION=TLS # How the connection is encrypted, either "NONE" (no encryption, port 25), "TLS" (sometimes called SSL, port 465) or "STARTTLS" (sometimes called TLS, port 587).
# - LLDAP_SMTP_OPTIONS__USER=no-reply@example.com # The SMTP user, usually your email address
# - LLDAP_SMTP_OPTIONS__PASSWORD=PasswordGoesHere # The SMTP password
# - LLDAP_SMTP_OPTIONS__FROM=no-reply <no-reply@example.com> # The header field, optional: how the sender appears in the email. The first is a free-form name, followed by an email between <>.
# - LLDAP_SMTP_OPTIONS__TO=admin <admin@example.com> # Same for reply-to, optional.
```
Then the service will listen on two ports, one for LDAP and one for the web
@@ -183,30 +194,225 @@ Depending on the distribution you use, it might be possible to install lldap
from a package repository, officially supported by the distribution or
community contributed.
#### Debian, CentOS Fedora, OpenSUSE, Ubuntu
Each package offers a [systemd service](https://wiki.archlinux.org/title/systemd#Using_units) `lldap.service` to (auto-)start and stop lldap.<br>
When using the distributed packages, the default login is `admin/password`. You can change that from the web UI after starting the service.
The package for these distributions can be found at [LLDAP OBS](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap).
- When using the distributed package, the default login is `admin/password`. You can change that from the web UI after starting the service.
<details>
<summary><b>Arch</b></summary>
<br>
Arch Linux offers unofficial support through the <a href="https://wiki.archlinux.org/title/Arch_User_Repository">Arch User Repository (AUR)</a>.<br>
The package descriptions can be used <a href="https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started">to create and install packages</a>.<br><br>
Maintainer: <a href="https://github.com/Zepmann">@Zepmann</a><br>
Support: <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
Package repository: <a href="https://aur.archlinux.org/packages">Arch user repository</a><br>
<table>
<tr>
<td>Available packages:</td>
<td><a href="https://aur.archlinux.org/packages/lldap">lldap</a></td>
<td>Builds the latest stable version.</td>
</tr>
<tr>
<td></td>
<td><a href="https://aur.archlinux.org/packages/lldap-bin">lldap-bin</a></td>
<td>Uses the latest pre-compiled binaries from the <a href="https://aur.archlinux.org/packages/lldap-bin">releases in this repository</a>.<br>
This package is recommended if you want to run lldap on a system with limited resources.</td>
</tr>
<tr>
<td></td>
<td><a href="https://aur.archlinux.org/packages/lldap-git">lldap-git</a></td>
<td>Builds the latest main branch code.</td>
</tr>
</table>
LLDPA configuration file: /etc/lldap.toml<br>
</details>
<details>
<summary><b>Debian</b></summary>
<br>
Unofficial Debian support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
<table>
<tr>
<td>Available packages:</td>
<td>lldap</td>
<td>Light LDAP server for authentication.</td>
</tr>
<tr>
<td></td>
<td>lldap-extras</td>
<td>Meta-Package for LLDAP and its tools and extensions.</td>
</tr>
<tr>
<td></td>
<td>lldap-migration-tool</td>
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-set-password</td>
<td>CLI tool to set a user password in LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-cli</td>
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
</tr>
</table>
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
</details>
<details>
<summary><b>CentOS</b></summary>
<br>
Unofficial CentOS support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
<table>
<tr>
<td>Available packages:</td>
<td>lldap</td>
<td>Light LDAP server for authentication.</td>
</tr>
<tr>
<td></td>
<td>lldap-extras</td>
<td>Meta-Package for LLDAP and its tools and extensions.</td>
</tr>
<tr>
<td></td>
<td>lldap-migration-tool</td>
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-set-password</td>
<td>CLI tool to set a user password in LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-cli</td>
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
</tr>
</table>
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
</details>
<details>
<summary><b>Fedora</b></summary>
<br>
Unofficial Fedora support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
<table>
<tr>
<td>Available packages:</td>
<td>lldap</td>
<td>Light LDAP server for authentication.</td>
</tr>
<tr>
<td></td>
<td>lldap-extras</td>
<td>Meta-Package for LLDAP and its tools and extensions.</td>
</tr>
<tr>
<td></td>
<td>lldap-migration-tool</td>
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-set-password</td>
<td>CLI tool to set a user password in LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-cli</td>
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
</tr>
</table>
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
</details>
<details>
<summary><b>OpenSUSE</b></summary>
<br>
Unofficial OpenSUSE support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
<table>
<tr>
<td>Available packages:</td>
<td>lldap</td>
<td>Light LDAP server for authentication.</td>
</tr>
<tr>
<td></td>
<td>lldap-extras</td>
<td>Meta-Package for LLDAP and its tools and extensions.</td>
</tr>
<tr>
<td></td>
<td>lldap-migration-tool</td>
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-set-password</td>
<td>CLI tool to set a user password in LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-cli</td>
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
</tr>
</table>
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
</details>
<details>
<summary><b>Ubuntu</b></summary>
<br>
Unofficial Ubuntu support is offered through the <a href="https://build.opensuse.org/">openSUSE Build Service</a>.<br><br>
Maintainer: <a href="https://github.com/Masgalor">@Masgalor</a><br>
Support: <a href="https://codeberg.org/Masgalor/LLDAP-Packaging/issues">Codeberg</a>, <a href="https://github.com/lldap/lldap/discussions">Discussions</a><br>
Package repository: <a href="https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap">SUSE openBuildService</a><br>
<table>
<tr>
<td>Available packages:</td>
<td>lldap</td>
<td>Light LDAP server for authentication.</td>
</tr>
<tr>
<td></td>
<td>lldap-extras</td>
<td>Meta-Package for LLDAP and its tools and extensions.</td>
</tr>
<tr>
<td></td>
<td>lldap-migration-tool</td>
<td>CLI migration tool to go from OpenLDAP to LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-set-password</td>
<td>CLI tool to set a user password in LLDAP.</td>
</tr>
<tr>
<td></td>
<td>lldap-cli</td>
<td>LLDAP-CLI is an unofficial command line interface for LLDAP.</td>
</tr>
</table>
LLDPA configuration file: /etc/lldap/lldap_config.toml<br>
</details>
#### Arch Linux
### With FreeBSD
Arch Linux offers unofficial support through the [Arch User Repository
(AUR)](https://wiki.archlinux.org/title/Arch_User_Repository).
Available package descriptions in AUR are:
You can also install it as a rc.d service in FreeBSD, see
[FreeBSD-install.md](example_configs/freebsd/freebsd-install.md).
- [lldap](https://aur.archlinux.org/packages/lldap) - Builds the latest stable version.
- [lldap-bin](https://aur.archlinux.org/packages/lldap-bin) - Uses the latest
pre-compiled binaries from the [releases in this repository](https://github.com/lldap/lldap/releases).
This package is recommended if you want to run lldap on a system with
limited resources.
- [lldap-git](https://aur.archlinux.org/packages/lldap-git) - Builds the
latest main branch code.
The package descriptions can be used
[to create and install packages](https://wiki.archlinux.org/title/Arch_User_Repository#Getting_started).
Each package places lldap's configuration file at `/etc/lldap.toml` and offers
[systemd service](https://wiki.archlinux.org/title/systemd#Using_units)
`lldap.service` to (auto-)start and stop lldap.
The rc.d script file
[rc.d_lldap](example_configs/freebsd/rc.d_lldap).
### From source
@@ -277,10 +483,16 @@ create users, set passwords, add them to groups and so on. Users can also
connect to the web UI and change their information, or request a password reset
link (if you configured the SMTP client).
Creating and managing custom attributes is currently in Beta. It's not
supported in the Web UI. The recommended way is to use
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli), a
community-contributed CLI frontend.
You can create and manage custom attributes through the Web UI, or through the
community-contributed CLI frontend (
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli)). This is necessary
for some service integrations.
The [bootstrap.sh](scripts/bootstrap.sh) script can enforce a list of
users/groups/attributes from a given file, reflecting it on the server.
To manage the user, group and membership lifecycle in an infrastructure-as-code
scenario you can use the unofficial [LLDAP terraform provider in the terraform registry](https://registry.terraform.io/providers/tasansga/lldap/latest).
LLDAP is also very scriptable, through its GraphQL API. See the
[Scripting](docs/scripting.md) docs for more info.
@@ -342,6 +554,13 @@ 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.
### Integration with OS's
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
configuration guide](example_configs/pam/README.md).
Integration with Windows (e.g. Samba) is WIP.
### Sample client configurations
Some specific clients have been tested to work and come with sample
@@ -355,6 +574,7 @@ folder for help with:
- [Authentik](example_configs/authentik.md)
- [Bookstack](example_configs/bookstack.env.example)
- [Calibre-Web](example_configs/calibre_web.md)
- [Carpal](example_configs/carpal.md)
- [Dell iDRAC](example_configs/dell_idrac.md)
- [Dex](example_configs/dex_config.yml)
- [Dokuwiki](example_configs/dokuwiki.md)
@@ -366,6 +586,7 @@ folder for help with:
- [GitLab](example_configs/gitlab.md)
- [Grafana](example_configs/grafana_ldap_config.toml)
- [Grocy](example_configs/grocy.md)
- [Harbor](example_configs/harbor.md)
- [Hedgedoc](example_configs/hedgedoc.md)
- [Home Assistant](example_configs/home-assistant.md)
- [Jellyfin](example_configs/jellyfin.md)
@@ -378,16 +599,24 @@ folder for help with:
- [Mastodon](example_configs/mastodon.env.example)
- [Matrix](example_configs/matrix_synapse.yml)
- [Mealie](example_configs/mealie.md)
- [Metabase](example_configs/metabase.md)
- [MegaRAC-BMC](example_configs/MegaRAC-SP-X-BMC.md)
- [MinIO](example_configs/minio.md)
- [Netbox](example_configs/netbox.md)
- [Nextcloud](example_configs/nextcloud.md)
- [Nexus](example_configs/nexus.md)
- [OCIS (OwnCloud Infinite Scale)](example_configs/ocis.md)
- [OneDev](example_configs/onedev.md)
- [Organizr](example_configs/Organizr.md)
- [Portainer](example_configs/portainer.md)
- [PowerDNS Admin](example_configs/powerdns_admin.md)
- [Prosody](example_configs/prosody.md)
- [Proxmox VE](example_configs/proxmox.md)
- [Radicale](example_configs/radicale.md)
- [Rancher](example_configs/rancher.md)
- [Seafile](example_configs/seafile.md)
- [Shaarli](example_configs/shaarli.md)
- [SonarQube](example_configs/sonarqube.md)
- [Squid](example_configs/squid.md)
- [Syncthing](example_configs/syncthing.md)
- [TheLounge](example_configs/thelounge.md)

View File

@@ -6,7 +6,7 @@ homepage = "https://github.com/lldap/lldap"
license = "GPL-3.0-only"
name = "lldap_app"
repository = "https://github.com/lldap/lldap"
version = "0.5.1-alpha"
version = "0.6.1"
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
[dependencies]
@@ -22,8 +22,8 @@ rand = "0.8"
serde = "1"
serde_json = "1"
url-escape = "0.1.1"
validator = "=0.14"
validator_derive = "*"
validator = "0.14"
validator_derive = "0.14"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "*"
yew = "0.19.3"
@@ -37,12 +37,16 @@ version = "0.3"
features = [
"Document",
"Element",
"Event",
"FileReader",
"FormData",
"HtmlDocument",
"HtmlFormElement",
"HtmlInputElement",
"HtmlOptionElement",
"HtmlOptionsCollection",
"HtmlSelectElement",
"SubmitEvent",
"console",
]
@@ -71,3 +75,10 @@ rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
[lib]
crate-type = ["cdylib"]
[package.metadata.wasm-pack.profile.dev]
wasm-opt = ['--enable-bulk-memory']
[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['--enable-bulk-memory']
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['--enable-bulk-memory']

View File

@@ -1,5 +1,5 @@
mutation CreateGroup($name: String!) {
createGroup(name: $name) {
mutation CreateGroup($group: CreateGroupInput!) {
createGroupWithDetails(request: $group) {
id
displayName
}

View File

@@ -0,0 +1,5 @@
mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!) {
addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: false) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation DeleteGroupAttributeQuery($name: String!) {
deleteGroupAttribute(name: $name) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation DeleteUserAttributeQuery($name: String!) {
deleteUserAttribute(name: $name) {
ok
}
}

View File

@@ -0,0 +1,14 @@
query GetGroupAttributesSchema {
schema {
groupSchema {
attributes {
name
attributeType
isList
isVisible
isHardcoded
isReadonly
}
}
}
}

View File

@@ -8,5 +8,22 @@ query GetGroupDetails($id: Int!) {
id
displayName
}
attributes {
name
value
}
}
schema {
groupSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}

View File

@@ -0,0 +1,15 @@
query GetUserAttributesSchema {
schema {
userSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -0,0 +1,6 @@
mutation UpdateGroup($group: UpdateGroupInput!) {
updateGroup(group: $group) {
ok
}
}

View File

@@ -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! {

View File

@@ -1,23 +1,26 @@
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 yew::{
function_component,
html::Scope,
@@ -30,25 +33,6 @@ use yew_router::{
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! {
@@ -135,10 +119,11 @@ 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">
@@ -227,6 +212,12 @@ impl App {
AppRoute::CreateGroup => html! {
<CreateGroupForm/>
},
AppRoute::CreateUserAttribute => html! {
<CreateUserAttributeForm/>
},
AppRoute::CreateGroupAttribute => html! {
<CreateGroupAttributeForm/>
},
AppRoute::ListGroups => html! {
<div>
<GroupTable />
@@ -236,8 +227,14 @@ impl App {
</Link>
</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 +260,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={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 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">

View File

@@ -0,0 +1,88 @@
use crate::infra::functional::{use_graphql_call, LoadableResult};
use graphql_client::GraphQLQuery;
use yew::{function_component, html, virtual_dom::AttrValue, Properties};
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_user_details.graphql",
variables_derives = "Clone,PartialEq,Eq",
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetUserDetails;
#[derive(Properties, PartialEq)]
pub struct Props {
pub user: AttrValue,
#[prop_or(32)]
pub width: i32,
#[prop_or(32)]
pub height: i32,
}
#[function_component(Avatar)]
pub fn avatar(props: &Props) -> Html {
let user_details = use_graphql_call::<GetUserDetails>(get_user_details::Variables {
id: props.user.to_string(),
});
match &(*user_details) {
LoadableResult::Loaded(Ok(response)) => {
let avatar = response.user.avatar.clone();
match &avatar {
Some(data) => html! {
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", data)}
style={format!("max-height:{}px;max-width:{}px;height:auto;width:auto;", props.height, props.width)}
alt="Avatar" />
},
None => html! {
<BlankAvatarDisplay
width={props.width}
height={props.height} />
},
}
}
LoadableResult::Loaded(Err(error)) => html! {
<BlankAvatarDisplay
error={error.to_string()}
width={props.width}
height={props.height} />
},
LoadableResult::Loading => html! {
<BlankAvatarDisplay
width={props.width}
height={props.height} />
},
}
}
#[derive(Properties, PartialEq)]
struct BlankAvatarDisplayProps {
#[prop_or(None)]
pub error: Option<AttrValue>,
pub width: i32,
pub height: i32,
}
#[function_component(BlankAvatarDisplay)]
fn blank_avatar_display(props: &BlankAvatarDisplayProps) -> Html {
let fill = match &props.error {
Some(_) => "red",
None => "currentColor",
};
html! {
<svg xmlns="http://www.w3.org/2000/svg"
width={props.width.to_string()}
height={props.height.to_string()}
fill={fill}
class="bi bi-person-circle"
viewBox="0 0 16 16">
<title>{props.error.clone().unwrap_or(AttrValue::Static("Avatar"))}</title>
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
}
}

View File

@@ -0,0 +1,132 @@
use crate::components::{
avatar::Avatar,
logout::LogoutButton,
router::{AppRoute, Link},
};
use wasm_bindgen::prelude::wasm_bindgen;
use yew::{function_component, html, Callback, Properties};
#[derive(Properties, PartialEq)]
pub struct Props {
pub is_admin: bool,
pub username: Option<String>,
pub on_logged_out: Callback<()>,
}
#[function_component(Banner)]
pub fn banner(props: &Props) -> Html {
html! {
<header class="p-2 mb-3 border-bottom">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
<h2>{"LLDAP"}</h2>
</a>
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
{if props.is_admin { html! {
<>
<li>
<Link
classes="nav-link px-2 h6"
to={AppRoute::ListUsers}>
<i class="bi-people me-2"></i>
{"Users"}
</Link>
</li>
<li>
<Link
classes="nav-link px-2 h6"
to={AppRoute::ListGroups}>
<i class="bi-collection me-2"></i>
{"Groups"}
</Link>
</li>
<li>
<Link
classes="nav-link px-2 h6"
to={AppRoute::ListUserSchema}>
<i class="bi-list-ul me-2"></i>
{"User schema"}
</Link>
</li>
<li>
<Link
classes="nav-link px-2 h6"
to={AppRoute::ListGroupSchema}>
<i class="bi-list-ul me-2"></i>
{"Group schema"}
</Link>
</li>
</>
} } else { html!{} } }
</ul>
<UserMenu username={props.username.clone()} on_logged_out={props.on_logged_out.clone()}/>
<DarkModeToggle />
</div>
</div>
</header>
}
}
#[derive(Properties, PartialEq)]
struct UserMenuProps {
pub username: Option<String>,
pub on_logged_out: Callback<()>,
}
#[function_component(UserMenu)]
fn user_menu(props: &UserMenuProps) -> Html {
match &props.username {
Some(username) => html! {
<div class="dropdown text-end">
<a href="#"
class="d-block nav-link text-decoration-none dropdown-toggle"
id="dropdownUser"
data-bs-toggle="dropdown"
aria-expanded="false">
<Avatar user={username.clone()} />
<span class="ms-2">
{username}
</span>
</a>
<ul
class="dropdown-menu text-small dropdown-menu-lg-end"
aria-labelledby="dropdownUser1"
style="">
<li>
<Link
classes="dropdown-item"
to={AppRoute::UserDetails{ user_id: username.to_string() }}>
{"View details"}
</Link>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<LogoutButton on_logged_out={props.on_logged_out.clone()} />
</li>
</ul>
</div>
},
_ => html! {},
}
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = darkmode)]
fn toggleDarkMode(doSave: bool);
#[wasm_bindgen]
fn inDarkMode() -> bool;
}
#[function_component(DarkModeToggle)]
fn dark_mode_toggle() -> Html {
html! {
<div class="form-check form-switch">
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
</div>
}
}

View File

@@ -1,5 +1,8 @@
use crate::{
components::router::{AppRoute, Link},
components::{
form::{field::Field, submit::Submit},
router::{AppRoute, Link},
},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@@ -207,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">
@@ -224,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>
</>
}

View File

@@ -1,8 +1,23 @@
use crate::{
components::router::AppRoute,
infra::common_component::{CommonComponent, CommonComponentParts},
components::{
form::{
attribute_input::{ListAttributeInput, SingleAttributeInput},
field::Field,
submit::Submit,
},
router::AppRoute,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
IsAdmin,
},
schema::AttributeType,
},
};
use anyhow::{bail, Result};
use anyhow::{ensure, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
@@ -10,6 +25,33 @@ 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"
)]
pub struct GetGroupAttributesSchema;
use get_group_attributes_schema::ResponseData;
pub type Attribute =
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
convert_attribute_type!(get_group_attributes_schema::AttributeType);
impl From<&Attribute> for GraphQlAttributeSchema {
fn from(attr: &Attribute) -> Self {
Self {
name: attr.name.clone(),
is_list: attr.is_list,
is_readonly: attr.is_readonly,
is_editable: false, // Need to be admin to edit it.
}
}
}
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
@@ -22,6 +64,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 +76,7 @@ pub struct CreateGroupModel {
pub enum Msg {
Update,
ListAttributesResponse(Result<ResponseData>),
SubmitForm,
CreateGroupResponse(Result<create_group::ResponseData>),
}
@@ -45,12 +90,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 +129,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 +151,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 +175,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 +212,21 @@ impl Component for CreateGroupForm {
}
}
}
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
if attribute_schema.is_list {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
}
}

View File

@@ -0,0 +1,168 @@
use crate::{
components::{
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
router::AppRoute,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::{validate_attribute_type, AttributeType},
},
};
use anyhow::{bail, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use yew::prelude::*;
use yew_form_derive::Model;
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/create_group_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct CreateGroupAttribute;
convert_attribute_type!(create_group_attribute::AttributeType);
pub struct CreateGroupAttributeForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateGroupAttributeModel>,
}
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
pub struct CreateGroupAttributeModel {
#[validate(length(min = 1, message = "attribute_name is required"))]
attribute_name: String,
#[validate(custom = "validate_attribute_type")]
attribute_type: String,
is_list: bool,
is_visible: bool, // remove when backend doesn't return group attributes for normal users
}
pub enum Msg {
Update,
SubmitForm,
CreateGroupAttributeResponse(Result<create_group_attribute::ResponseData>),
}
impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitForm => {
if !self.form.validate() {
bail!("Check the form for errors");
}
let model = self.form.model();
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
let req = create_group_attribute::Variables {
name: model.attribute_name,
attribute_type: create_group_attribute::AttributeType::from(attribute_type),
is_list: model.is_list,
is_visible: model.is_visible,
};
self.common.call_graphql::<CreateGroupAttribute, _>(
ctx,
req,
Msg::CreateGroupAttributeResponse,
"Error trying to create group attribute",
);
Ok(true)
}
Msg::CreateGroupAttributeResponse(response) => {
response?;
let model = self.form.model();
log!(&format!(
"Created group attribute '{}'",
model.attribute_name
));
ctx.link()
.history()
.unwrap()
.push(AppRoute::ListGroupSchema);
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for CreateGroupAttributeForm {
type Message = Msg;
type Properties = ();
fn create(_: &Context<Self>) -> Self {
let model = CreateGroupAttributeModel {
attribute_type: AttributeType::String.to_string(),
..Default::default()
};
Self {
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateGroupAttributeModel>::new(model),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="row justify-content-center">
<form class="form py-3" style="max-width: 636px">
<h5 class="fw-bold">{"Create a group attribute"}</h5>
<Field<CreateGroupAttributeModel>
label="Name"
required={true}
form={&self.form}
field_name="attribute_name"
oninput={link.callback(|_| Msg::Update)} />
<Select<CreateGroupAttributeModel>
label="Type"
required={true}
form={&self.form}
field_name="attribute_type"
oninput={link.callback(|_| Msg::Update)}>
<option selected=true value="String">{"String"}</option>
<option value="Integer">{"Integer"}</option>
<option value="Jpeg">{"Jpeg"}</option>
<option value="DateTime">{"DateTime"}</option>
</Select<CreateGroupAttributeModel>>
<CheckBox<CreateGroupAttributeModel>
label="Multiple values"
form={&self.form}
field_name="is_list"
ontoggle={link.callback(|_| Msg::Update)} />
<CheckBox<CreateGroupAttributeModel>
label="Visible to users"
form={&self.form}
field_name="is_visible"
ontoggle={link.callback(|_| Msg::Update)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
</form>
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
</div>
}
}
}

View File

@@ -1,11 +1,24 @@
use crate::{
components::router::AppRoute,
components::{
form::{
attribute_input::{ListAttributeInput, SingleAttributeInput},
field::Field,
submit::Submit,
},
router::AppRoute,
},
convert_attribute_type,
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
form_utils::{
read_all_form_attributes, AttributeValue, EmailIsRequired, GraphQlAttributeSchema,
IsAdmin,
},
schema::AttributeType,
},
};
use anyhow::{bail, Result};
use anyhow::{ensure, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use lldap_auth::{opaque, registration};
@@ -14,6 +27,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_user_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetUserAttributesSchema;
use get_user_attributes_schema::ResponseData;
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
convert_attribute_type!(get_user_attributes_schema::AttributeType);
impl From<&Attribute> for GraphQlAttributeSchema {
fn from(attr: &Attribute) -> Self {
Self {
name: attr.name.clone(),
is_list: attr.is_list,
is_readonly: attr.is_readonly,
is_editable: attr.is_editable,
}
}
}
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
@@ -26,17 +65,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 +92,7 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
pub enum Msg {
Update,
ListAttributesResponse(Result<ResponseData>),
SubmitForm,
CreateUserResponse(Result<create_user::ResponseData>),
SuccessfulCreation,
@@ -76,21 +113,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: None,
attributes,
},
};
self.common.call_graphql::<CreateUser, _>(
@@ -174,11 +233,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 {
@@ -187,163 +255,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 {
@@ -358,3 +304,21 @@ impl Component for CreateUserForm {
}
}
}
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
if attribute_schema.is_list {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
/>
}
}
}

View File

@@ -0,0 +1,175 @@
use crate::{
components::{
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
router::AppRoute,
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::{validate_attribute_type, AttributeType},
},
};
use anyhow::{bail, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use yew::prelude::*;
use yew_form_derive::Model;
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/create_user_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct CreateUserAttribute;
convert_attribute_type!(create_user_attribute::AttributeType);
pub struct CreateUserAttributeForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateUserAttributeModel>,
}
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
pub struct CreateUserAttributeModel {
#[validate(length(min = 1, message = "attribute_name is required"))]
attribute_name: String,
#[validate(custom = "validate_attribute_type")]
attribute_type: String,
is_editable: bool,
is_list: bool,
is_visible: bool,
}
pub enum Msg {
Update,
SubmitForm,
CreateUserAttributeResponse(Result<create_user_attribute::ResponseData>),
}
impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitForm => {
if !self.form.validate() {
bail!("Check the form for errors");
}
let model = self.form.model();
if model.is_editable && !model.is_visible {
bail!("Editable attributes must also be visible");
}
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
let req = create_user_attribute::Variables {
name: model.attribute_name,
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
is_editable: model.is_editable,
is_list: model.is_list,
is_visible: model.is_visible,
};
self.common.call_graphql::<CreateUserAttribute, _>(
ctx,
req,
Msg::CreateUserAttributeResponse,
"Error trying to create user attribute",
);
Ok(true)
}
Msg::CreateUserAttributeResponse(response) => {
response?;
let model = self.form.model();
log!(&format!(
"Created user attribute '{}'",
model.attribute_name
));
ctx.link().history().unwrap().push(AppRoute::ListUserSchema);
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for CreateUserAttributeForm {
type Message = Msg;
type Properties = ();
fn create(_: &Context<Self>) -> Self {
let model = CreateUserAttributeModel {
attribute_type: AttributeType::String.to_string(),
..Default::default()
};
Self {
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateUserAttributeModel>::new(model),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="row justify-content-center">
<form class="form py-3" style="max-width: 636px">
<h5 class="fw-bold">{"Create a user attribute"}</h5>
<Field<CreateUserAttributeModel>
label="Name"
required={true}
form={&self.form}
field_name="attribute_name"
oninput={link.callback(|_| Msg::Update)} />
<Select<CreateUserAttributeModel>
label="Type"
required={true}
form={&self.form}
field_name="attribute_type"
oninput={link.callback(|_| Msg::Update)}>
<option selected=true value="String">{"String"}</option>
<option value="Integer">{"Integer"}</option>
<option value="Jpeg">{"Jpeg"}</option>
<option value="DateTime">{"DateTime"}</option>
</Select<CreateUserAttributeModel>>
<CheckBox<CreateUserAttributeModel>
label="Multiple values"
form={&self.form}
field_name="is_list"
ontoggle={link.callback(|_| Msg::Update)} />
<CheckBox<CreateUserAttributeModel>
label="Visible to users"
form={&self.form}
field_name="is_visible"
ontoggle={link.callback(|_| Msg::Update)} />
<CheckBox<CreateUserAttributeModel>
label="Editable by users"
form={&self.form}
field_name="is_editable"
ontoggle={link.callback(|_| Msg::Update)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
</form>
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
</div>
}
}
}

View File

@@ -0,0 +1,172 @@
use crate::infra::{
common_component::{CommonComponent, CommonComponentParts},
modal::Modal,
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/delete_group_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct DeleteGroupAttributeQuery;
pub struct DeleteGroupAttribute {
common: CommonComponentParts<Self>,
node_ref: NodeRef,
modal: Option<Modal>,
}
#[derive(yew::Properties, Clone, PartialEq, Debug)]
pub struct DeleteGroupAttributeProps {
pub attribute_name: String,
pub on_attribute_deleted: Callback<String>,
pub on_error: Callback<Error>,
}
pub enum Msg {
ClickedDeleteGroupAttribute,
ConfirmDeleteGroupAttribute,
DismissModal,
DeleteGroupAttributeResponse(Result<delete_group_attribute_query::ResponseData>),
}
impl CommonComponent<DeleteGroupAttribute> for DeleteGroupAttribute {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::ClickedDeleteGroupAttribute => {
self.modal.as_ref().expect("modal not initialized").show();
}
Msg::ConfirmDeleteGroupAttribute => {
self.update(ctx, Msg::DismissModal);
self.common.call_graphql::<DeleteGroupAttributeQuery, _>(
ctx,
delete_group_attribute_query::Variables {
name: ctx.props().attribute_name.clone(),
},
Msg::DeleteGroupAttributeResponse,
"Error trying to delete group attribute",
);
}
Msg::DismissModal => {
self.modal.as_ref().expect("modal not initialized").hide();
}
Msg::DeleteGroupAttributeResponse(response) => {
response?;
ctx.props()
.on_attribute_deleted
.emit(ctx.props().attribute_name.clone());
}
}
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for DeleteGroupAttribute {
type Message = Msg;
type Properties = DeleteGroupAttributeProps;
fn create(_: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(),
node_ref: NodeRef::default(),
modal: None,
}
}
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
if first_render {
self.modal = Some(Modal::new(
self.node_ref
.cast::<web_sys::Element>()
.expect("Modal node is not an element"),
));
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error(
self,
ctx,
msg,
ctx.props().on_error.clone(),
)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<>
<button
class="btn btn-danger"
disabled={self.common.is_task_running()}
onclick={link.callback(|_| Msg::ClickedDeleteGroupAttribute)}>
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
</button>
{self.show_modal(ctx)}
</>
}
}
}
impl DeleteGroupAttribute {
fn show_modal(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<div
class="modal fade"
id={"deleteGroupAttributeModal".to_string() + &ctx.props().attribute_name}
tabindex="-1"
aria-labelledby="deleteGroupAttributeModalLabel"
aria-hidden="true"
ref={self.node_ref.clone()}>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteGroupAttributeModalLabel">{"Delete group attribute?"}</h5>
<button
type="button"
class="btn-close"
aria-label="Close"
onclick={link.callback(|_| Msg::DismissModal)} />
</div>
<div class="modal-body">
<span>
{"Are you sure you want to delete group attribute "}
<b>{&ctx.props().attribute_name}</b>{"?"}
</span>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick={link.callback(|_| Msg::DismissModal)}>
<i class="bi-x-circle me-2"></i>
{"Cancel"}
</button>
<button
type="button"
onclick={link.callback(|_| Msg::ConfirmDeleteGroupAttribute)}
class="btn btn-danger">
<i class="bi-check-circle me-2"></i>
{"Yes, I'm sure"}
</button>
</div>
</div>
</div>
</div>
}
}
}

View File

@@ -0,0 +1,172 @@
use crate::infra::{
common_component::{CommonComponent, CommonComponentParts},
modal::Modal,
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/delete_user_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct DeleteUserAttributeQuery;
pub struct DeleteUserAttribute {
common: CommonComponentParts<Self>,
node_ref: NodeRef,
modal: Option<Modal>,
}
#[derive(yew::Properties, Clone, PartialEq, Debug)]
pub struct DeleteUserAttributeProps {
pub attribute_name: String,
pub on_attribute_deleted: Callback<String>,
pub on_error: Callback<Error>,
}
pub enum Msg {
ClickedDeleteUserAttribute,
ConfirmDeleteUserAttribute,
DismissModal,
DeleteUserAttributeResponse(Result<delete_user_attribute_query::ResponseData>),
}
impl CommonComponent<DeleteUserAttribute> for DeleteUserAttribute {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::ClickedDeleteUserAttribute => {
self.modal.as_ref().expect("modal not initialized").show();
}
Msg::ConfirmDeleteUserAttribute => {
self.update(ctx, Msg::DismissModal);
self.common.call_graphql::<DeleteUserAttributeQuery, _>(
ctx,
delete_user_attribute_query::Variables {
name: ctx.props().attribute_name.clone(),
},
Msg::DeleteUserAttributeResponse,
"Error trying to delete user attribute",
);
}
Msg::DismissModal => {
self.modal.as_ref().expect("modal not initialized").hide();
}
Msg::DeleteUserAttributeResponse(response) => {
response?;
ctx.props()
.on_attribute_deleted
.emit(ctx.props().attribute_name.clone());
}
}
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for DeleteUserAttribute {
type Message = Msg;
type Properties = DeleteUserAttributeProps;
fn create(_: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(),
node_ref: NodeRef::default(),
modal: None,
}
}
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
if first_render {
self.modal = Some(Modal::new(
self.node_ref
.cast::<web_sys::Element>()
.expect("Modal node is not an element"),
));
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error(
self,
ctx,
msg,
ctx.props().on_error.clone(),
)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<>
<button
class="btn btn-danger"
disabled={self.common.is_task_running()}
onclick={link.callback(|_| Msg::ClickedDeleteUserAttribute)}>
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
</button>
{self.show_modal(ctx)}
</>
}
}
}
impl DeleteUserAttribute {
fn show_modal(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<div
class="modal fade"
id={"deleteUserAttributeModal".to_string() + &ctx.props().attribute_name}
tabindex="-1"
aria-labelledby="deleteUserAttributeModalLabel"
aria-hidden="true"
ref={self.node_ref.clone()}>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteUserAttributeModalLabel">{"Delete user attribute?"}</h5>
<button
type="button"
class="btn-close"
aria-label="Close"
onclick={link.callback(|_| Msg::DismissModal)} />
</div>
<div class="modal-body">
<span>
{"Are you sure you want to delete user attribute "}
<b>{&ctx.props().attribute_name}</b>{"?"}
</span>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick={link.callback(|_| Msg::DismissModal)}>
<i class="bi-x-circle me-2"></i>
{"Cancel"}
</button>
<button
type="button"
onclick={link.callback(|_| Msg::ConfirmDeleteUserAttribute)}
class="btn btn-danger">
<i class="bi-check-circle me-2"></i>
{"Yes, I'm sure"}
</button>
</div>
</div>
</div>
</div>
}
}
}

View File

@@ -0,0 +1,190 @@
use crate::{
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
infra::{schema::AttributeType, tooltip::Tooltip},
};
use web_sys::Element;
use yew::{
function_component, html, use_effect_with_deps, use_node_ref, virtual_dom::AttrValue,
Component, Context, Html, Properties,
};
#[derive(Properties, PartialEq)]
struct AttributeInputProps {
name: AttrValue,
attribute_type: AttributeType,
#[prop_or(None)]
value: Option<String>,
}
#[function_component(AttributeInput)]
fn attribute_input(props: &AttributeInputProps) -> Html {
let input_type = match props.attribute_type {
AttributeType::String => "text",
AttributeType::Integer => "number",
AttributeType::DateTime => {
return html! {
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
}
}
AttributeType::Jpeg => {
return html! {
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
}
}
};
html! {
<input
type={input_type}
name={props.name.clone()}
class="form-control"
value={props.value.clone()} />
}
}
#[derive(Properties, PartialEq)]
struct AttributeLabelProps {
pub name: String,
}
#[function_component(AttributeLabel)]
fn attribute_label(props: &AttributeLabelProps) -> Html {
let tooltip_ref = use_node_ref();
use_effect_with_deps(
move |tooltip_ref| {
Tooltip::new(
tooltip_ref
.cast::<Element>()
.expect("Tooltip element should exist"),
);
|| {}
},
tooltip_ref.clone(),
);
html! {
<label for={props.name.clone()}
class="form-label col-4 col-form-label"
>
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
<button
class="btn btn-sm btn-link"
type="button"
data-bs-placement="right"
title={props.name.clone()}
ref={tooltip_ref}>
<i class="bi bi-info-circle" aria-label="Info" />
</button>
</label>
}
}
#[derive(Properties, PartialEq)]
pub struct SingleAttributeInputProps {
pub name: String,
pub attribute_type: AttributeType,
#[prop_or(None)]
pub value: Option<String>,
}
#[function_component(SingleAttributeInput)]
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
html! {
<div class="row mb-3">
<AttributeLabel name={props.name.clone()} />
<div class="col-8">
<AttributeInput
attribute_type={props.attribute_type.clone()}
name={props.name.clone()}
value={props.value.clone()} />
</div>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct ListAttributeInputProps {
pub name: String,
pub attribute_type: AttributeType,
#[prop_or(vec!())]
pub values: Vec<String>,
}
pub enum ListAttributeInputMsg {
Remove(usize),
Append,
}
pub struct ListAttributeInput {
indices: Vec<usize>,
next_index: usize,
values: Vec<String>,
}
impl Component for ListAttributeInput {
type Message = ListAttributeInputMsg;
type Properties = ListAttributeInputProps;
fn create(ctx: &Context<Self>) -> Self {
let values = ctx.props().values.clone();
Self {
indices: (0..values.len()).collect(),
next_index: values.len(),
values,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
ListAttributeInputMsg::Remove(removed) => {
self.indices.retain_mut(|x| *x != removed);
}
ListAttributeInputMsg::Append => {
self.indices.push(self.next_index);
self.next_index += 1;
}
};
true
}
fn changed(&mut self, ctx: &Context<Self>) -> bool {
if ctx.props().values != self.values {
self.values.clone_from(&ctx.props().values);
self.indices = (0..self.values.len()).collect();
self.next_index = self.values.len();
}
true
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = &ctx.props();
let link = &ctx.link();
html! {
<div class="row mb-3">
<AttributeLabel name={props.name.clone()} />
<div class="col-8">
{self.indices.iter().map(|&i| html! {
<div class="input-group mb-2" key={i}>
<AttributeInput
attribute_type={props.attribute_type.clone()}
name={props.name.clone()}
value={props.values.get(i).cloned().unwrap_or_default()} />
<button
class="btn btn-danger"
type="button"
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
<i class="bi-x-circle-fill" aria-label="Remove value" />
</button>
</div>
}).collect::<Html>()}
<button
class="btn btn-secondary"
type="button"
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
<i class="bi-plus-circle me-2"></i>
{"Add value"}
</button>
</div>
</div>
}
}
}

View File

@@ -0,0 +1,35 @@
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Properties};
use yew_form::{Form, Model};
#[derive(Properties, PartialEq)]
pub struct Props<T: Model> {
pub label: AttrValue,
pub field_name: String,
pub form: Form<T>,
#[prop_or(false)]
pub required: bool,
#[prop_or_else(Callback::noop)]
pub ontoggle: Callback<bool>,
}
#[function_component(CheckBox)]
pub fn checkbox<T: Model>(props: &Props<T>) -> Html {
html! {
<div class="form-group row mb-3">
<label for={props.field_name.clone()}
class="form-label col-4 col-form-label">
{&props.label}
{if props.required {
html!{<span class="text-danger">{"*"}</span>}
} else {html!{}}}
{":"}
</label>
<div class="col-8">
<yew_form::CheckBox<T>
form={&props.form}
field_name={props.field_name.clone()}
ontoggle={props.ontoggle.clone()} />
</div>
</div>
}
}

View File

@@ -0,0 +1,49 @@
use std::str::FromStr;
use chrono::{DateTime, NaiveDateTime, Utc};
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::{function_component, html, use_state, virtual_dom::AttrValue, Event, Properties};
#[derive(Properties, PartialEq)]
pub struct DateTimeInputProps {
pub name: AttrValue,
pub value: Option<String>,
}
#[function_component(DateTimeInput)]
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
let value = use_state(|| {
props
.value
.as_ref()
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
});
html! {
<div class="input-group">
<input
type="hidden"
name={props.name.clone()}
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
<input
type="datetime-local"
step="1"
class="form-control"
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
onchange={move |e: Event| {
let string_val =
e.target()
.expect("Event should have target")
.unchecked_into::<HtmlInputElement>()
.value();
value.set(
NaiveDateTime::from_str(&string_val)
.ok()
.map(|x| DateTime::from_naive_utc_and_offset(x, Utc))
)
}} />
<span class="input-group-text">{"UTC"}</span>
</div>
}
}

View File

@@ -0,0 +1,48 @@
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties};
use yew_form::{Form, Model};
#[derive(Properties, PartialEq)]
pub struct Props<T: Model> {
pub label: AttrValue,
pub field_name: String,
pub form: Form<T>,
#[prop_or(false)]
pub required: bool,
#[prop_or(String::from("text"))]
pub input_type: String,
// If not present, will default to field_name
#[prop_or(None)]
pub autocomplete: Option<String>,
#[prop_or_else(Callback::noop)]
pub oninput: Callback<InputEvent>,
}
#[function_component(Field)]
pub fn field<T: Model>(props: &Props<T>) -> Html {
html! {
<div class="row mb-3">
<label for={props.field_name.clone()}
class="form-label col-4 col-form-label">
{&props.label}
{if props.required {
html!{<span class="text-danger">{"*"}</span>}
} else {html!{}}}
{":"}
</label>
<div class="col-8">
<yew_form::Field<T>
form={&props.form}
field_name={props.field_name.clone()}
input_type={props.input_type.clone()}
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete={props.autocomplete.clone().unwrap_or(props.field_name.clone())}
oninput={&props.oninput} />
<div class="invalid-feedback">
{&props.form.field_message(&props.field_name)}
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,238 @@
use std::{fmt::Display, str::FromStr};
use anyhow::{bail, Error, Ok, Result};
use gloo_file::{
callbacks::{read_as_bytes, FileReader},
File,
};
use web_sys::{FileList, HtmlInputElement, InputEvent};
use yew::Properties;
use yew::{prelude::*, virtual_dom::AttrValue};
#[derive(Default)]
struct JsFile {
file: Option<File>,
contents: Option<Vec<u8>>,
}
impl Display for JsFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
self.file.as_ref().map(File::name).unwrap_or_default()
)
}
}
impl FromStr for JsFile {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
if s.is_empty() {
Ok(JsFile::default())
} else {
bail!("Building file from non-empty string")
}
}
}
fn to_base64(file: &JsFile) -> Result<String> {
match file {
JsFile {
file: None,
contents: None,
} => Ok(String::new()),
JsFile {
file: Some(_),
contents: None,
} => bail!("Image file hasn't finished loading, try again"),
JsFile {
file: Some(_),
contents: Some(data),
} => {
if !is_valid_jpeg(data.as_slice()) {
bail!("Chosen image is not a valid JPEG");
}
Ok(base64::encode(data))
}
JsFile {
file: None,
contents: Some(data),
} => Ok(base64::encode(data)),
}
}
/// A [yew::Component] to display the user details, with a form allowing to edit them.
pub struct JpegFileInput {
// None means that the avatar hasn't changed.
avatar: Option<JsFile>,
reader: Option<FileReader>,
}
pub enum Msg {
Update,
/// A new file was selected.
FileSelected(File),
/// The "Clear" button for the avatar was clicked.
ClearClicked,
/// A picked file finished loading.
FileLoaded(String, Result<Vec<u8>>),
}
#[derive(Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub name: AttrValue,
pub value: Option<String>,
}
impl Component for JpegFileInput {
type Message = Msg;
type Properties = Props;
fn create(ctx: &Context<Self>) -> Self {
Self {
avatar: Some(JsFile {
file: None,
contents: ctx
.props()
.value
.as_ref()
.and_then(|x| base64::decode(x).ok()),
}),
reader: None,
}
}
fn changed(&mut self, ctx: &Context<Self>) -> bool {
self.avatar = Some(JsFile {
file: None,
contents: ctx
.props()
.value
.as_ref()
.and_then(|x| base64::decode(x).ok()),
});
self.reader = None;
true
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Update => true,
Msg::FileSelected(new_avatar) => {
if self
.avatar
.as_ref()
.and_then(|f| f.file.as_ref().map(|f| f.name()))
!= Some(new_avatar.name())
{
let file_name = new_avatar.name();
let link = ctx.link().clone();
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
link.send_message(Msg::FileLoaded(
file_name,
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
))
}));
self.avatar = Some(JsFile {
file: Some(new_avatar),
contents: None,
});
}
true
}
Msg::ClearClicked => {
self.avatar = Some(JsFile::default());
true
}
Msg::FileLoaded(file_name, data) => {
if let Some(avatar) = &mut self.avatar {
if let Some(file) = &avatar.file {
if file.name() == file_name {
if let Result::Ok(data) = data {
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = Some(JsFile::default());
// TODO: bail!("Chosen image is not a valid JPEG");
} else {
avatar.contents = Some(data);
return true;
}
}
}
}
}
self.reader = None;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
let avatar_string = match &self.avatar {
Some(avatar) => {
let avatar_base64 = to_base64(avatar);
avatar_base64.as_deref().unwrap_or("").to_owned()
}
None => String::new(),
};
html! {
<div class="row align-items-center">
<div class="col-5">
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Self::upload_files(input.files())
})} />
</div>
<div class="col-3">
<button
class="btn btn-secondary col-auto"
id="avatarClear"
type="button"
onclick={link.callback(|_| {Msg::ClearClicked})}>
{"Clear"}
</button>
</div>
<div class="col-4">
{
if !avatar_string.is_empty() {
html!{
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", avatar_string)}
style="max-height:128px;max-width:128px;height:auto;width:auto;"
alt="Avatar" />
}
} else { html! {} }
}
</div>
</div>
}
}
}
impl JpegFileInput {
fn upload_files(files: Option<FileList>) -> Msg {
match files {
Some(files) if files.length() > 0 => {
Msg::FileSelected(File::from(files.item(0).unwrap()))
}
Some(_) | None => Msg::Update,
}
}
}
fn is_valid_jpeg(bytes: &[u8]) -> bool {
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()
.is_ok()
}

View File

@@ -0,0 +1,8 @@
pub mod attribute_input;
pub mod checkbox;
pub mod date_input;
pub mod field;
pub mod file_input;
pub mod select;
pub mod static_value;
pub mod submit;

View File

@@ -0,0 +1,46 @@
use yew::{
function_component, html, virtual_dom::AttrValue, Callback, Children, InputEvent, Properties,
};
use yew_form::{Form, Model};
#[derive(Properties, PartialEq)]
pub struct Props<T: Model> {
pub label: AttrValue,
pub field_name: String,
pub form: Form<T>,
#[prop_or(false)]
pub required: bool,
#[prop_or_else(Callback::noop)]
pub oninput: Callback<InputEvent>,
pub children: Children,
}
#[function_component(Select)]
pub fn select<T: Model>(props: &Props<T>) -> Html {
html! {
<div class="row mb-3">
<label for={props.field_name.clone()}
class="form-label col-4 col-form-label">
{&props.label}
{if props.required {
html!{<span class="text-danger">{"*"}</span>}
} else {html!{}}}
{":"}
</label>
<div class="col-8">
<yew_form::Select<T>
form={&props.form}
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
field_name={props.field_name.clone()}
oninput={&props.oninput} >
{for props.children.iter()}
</yew_form::Select<T>>
<div class="invalid-feedback">
{&props.form.field_message(&props.field_name)}
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,26 @@
use yew::{function_component, html, virtual_dom::AttrValue, Children, Properties};
#[derive(Properties, PartialEq)]
pub struct Props {
pub label: AttrValue,
pub id: AttrValue,
pub children: Children,
}
#[function_component(StaticValue)]
pub fn static_value(props: &Props) -> Html {
html! {
<div class="row mb-3">
<label for={props.id.clone()}
class="form-label col-4 col-form-label">
{&props.label}
{":"}
</label>
<div class="col-8">
<span id={props.id.clone()} class="form-control-static">
{for props.children.iter()}
</span>
</div>
</div>
}
}

View File

@@ -0,0 +1,30 @@
use web_sys::MouseEvent;
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Children, Properties};
#[derive(Properties, PartialEq)]
pub struct Props {
pub disabled: bool,
pub onclick: Callback<MouseEvent>,
// Additional elements to insert after the button, in the same div
#[prop_or_default]
pub children: Children,
#[prop_or(AttrValue::from("Submit"))]
pub text: AttrValue,
}
#[function_component(Submit)]
pub fn submit(props: &Props) -> Html {
html! {
<div class="form-group row justify-content-center">
<button
class="btn btn-primary col-auto col-form-label"
type="submit"
disabled={props.disabled}
onclick={&props.onclick}>
<i class="bi-save me-2"></i>
{props.text.clone()}
</button>
{for props.children.iter()}
</div>
}
}

View File

@@ -1,10 +1,15 @@
use crate::{
components::{
add_group_member::{self, AddGroupMemberComponent},
group_details_form::GroupDetailsForm,
remove_user_from_group::RemoveUserFromGroupComponent,
router::{AppRoute, Link},
},
infra::common_component::{CommonComponent, CommonComponentParts},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::GraphQlAttributeSchema,
},
};
use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery;
@@ -22,12 +27,28 @@ pub struct GetGroupDetails;
pub type Group = get_group_details::GetGroupDetailsGroup;
pub type User = get_group_details::GetGroupDetailsGroupUsers;
pub type AddGroupMemberUser = add_group_member::User;
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
pub type AttributeType = get_group_details::AttributeType;
convert_attribute_type!(AttributeType);
impl From<&AttributeSchema> for GraphQlAttributeSchema {
fn from(attr: &AttributeSchema) -> Self {
Self {
name: attr.name.clone(),
is_list: attr.is_list,
is_readonly: attr.is_readonly,
is_editable: attr.is_editable,
}
}
}
pub struct GroupDetails {
common: CommonComponentParts<Self>,
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
/// received the server response yet.
group: Option<Group>,
group_and_schema: Option<(Group, Vec<AttributeSchema>)>,
}
/// State machine describing the possible transitions of the component state.
@@ -38,11 +59,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 +92,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 +180,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 +228,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 +239,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>
}

View File

@@ -0,0 +1,272 @@
use crate::{
components::{
form::{
attribute_input::{ListAttributeInput, SingleAttributeInput},
static_value::StaticValue,
submit::Submit,
},
group_details::{Attribute, AttributeSchema, Group},
},
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
schema::AttributeType,
},
};
use anyhow::{Ok, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
/// The GraphQL query sent to the server to update the group details.
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/update_group.graphql",
response_derives = "Debug",
variables_derives = "Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct UpdateGroup;
/// A [yew::Component] to display the group details, with a form allowing to edit them.
pub struct GroupDetailsForm {
common: CommonComponentParts<Self>,
/// True if we just successfully updated the group, to display a success message.
just_updated: bool,
updated_group_name: bool,
group: Group,
form_ref: NodeRef,
}
pub enum Msg {
/// A form field changed.
Update,
/// The "Submit" button was clicked.
SubmitClicked,
/// We got the response from the server about our update message.
GroupUpdated(Result<update_group::ResponseData>),
}
#[derive(yew::Properties, Clone, PartialEq)]
pub struct Props {
/// The current group details.
pub group: Group,
pub group_attributes_schema: Vec<AttributeSchema>,
pub is_admin: bool,
pub on_display_name_updated: Callback<()>,
}
impl CommonComponent<GroupDetailsForm> for GroupDetailsForm {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitClicked => self.submit_group_update_form(ctx),
Msg::GroupUpdated(Err(e)) => Err(e),
Msg::GroupUpdated(Result::Ok(_)) => {
self.just_updated = true;
if self.updated_group_name {
self.updated_group_name = false;
ctx.props().on_display_name_updated.emit(());
}
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for GroupDetailsForm {
type Message = Msg;
type Properties = Props;
fn create(ctx: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(),
just_updated: false,
updated_group_name: false,
group: ctx.props().group.clone(),
form_ref: NodeRef::default(),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
self.just_updated = false;
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
let can_edit =
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
let display_field = |a: &AttributeSchema| {
if can_edit(a) {
get_custom_attribute_input(a, &self.group.attributes)
} else {
get_custom_attribute_static(a, &self.group.attributes)
}
};
html! {
<div class="py-3">
<form
class="form"
ref={self.form_ref.clone()}>
<StaticValue label="Group ID" id="groupId">
<i>{&self.group.id}</i>
</StaticValue>
{
ctx
.props()
.group_attributes_schema
.iter()
.filter(|a| a.is_hardcoded && a.name != "group_id")
.map(display_field)
.collect::<Vec<_>>()
}
{
ctx
.props()
.group_attributes_schema
.iter()
.filter(|a| !a.is_hardcoded)
.map(display_field)
.collect::<Vec<_>>()
}
<Submit
text="Save changes"
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
</form>
{
if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
<div hidden={!self.just_updated}>
<div class="alert alert-success mt-4">{"Group successfully updated!"}</div>
</div>
</div>
}
}
}
fn get_custom_attribute_input(
attribute_schema: &AttributeSchema,
group_attributes: &[Attribute],
) -> Html {
let values = group_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
if attribute_schema.is_list {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
values={values}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
value={values.first().cloned().unwrap_or_default()}
/>
}
}
}
fn get_custom_attribute_static(
attribute_schema: &AttributeSchema,
group_attributes: &[Attribute],
) -> Html {
let values = group_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
html! {
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
</StaticValue>
}
}
impl GroupDetailsForm {
fn submit_group_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
let mut all_values = read_all_form_attributes(
ctx.props().group_attributes_schema.iter(),
&self.form_ref,
IsAdmin(ctx.props().is_admin),
EmailIsRequired(false),
)?;
let base_attributes = &self.group.attributes;
all_values.retain(|a| {
let base_val = base_attributes
.iter()
.find(|base_val| base_val.name == a.name);
base_val
.map(|v| v.value != a.values)
.unwrap_or(!a.values.is_empty())
});
if all_values.iter().any(|a| a.name == "display_name") {
self.updated_group_name = true;
}
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
None
} else {
Some(all_values.iter().map(|a| a.name.clone()).collect())
};
let insert_attributes: Option<Vec<update_group::AttributeValueInput>> =
if remove_attributes.is_none() {
None
} else {
Some(
all_values
.into_iter()
.filter(|a| !a.values.is_empty())
.map(
|AttributeValue { name, values }| update_group::AttributeValueInput {
name,
value: values,
},
)
.collect(),
)
};
let mut group_input = update_group::UpdateGroupInput {
id: self.group.id,
displayName: None,
removeAttributes: None,
insertAttributes: None,
};
let default_group_input = group_input.clone();
group_input.removeAttributes = remove_attributes;
group_input.insertAttributes = insert_attributes;
// Nothing changed.
if group_input == default_group_input {
return Ok(false);
}
let req = update_group::Variables { group: group_input };
self.common.call_graphql::<UpdateGroup, _>(
ctx,
req,
Msg::GroupUpdated,
"Error trying to update group",
);
Ok(false)
}
}

View File

@@ -0,0 +1,198 @@
use crate::{
components::{
delete_group_attribute::DeleteGroupAttribute,
router::{AppRoute, Link},
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::AttributeType,
},
};
use anyhow::{anyhow, Error, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_group_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetGroupAttributesSchema;
use get_group_attributes_schema::ResponseData;
pub type Attribute =
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
convert_attribute_type!(get_group_attributes_schema::AttributeType);
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub hardcoded: bool,
}
pub struct GroupSchemaTable {
common: CommonComponentParts<Self>,
attributes: Option<Vec<Attribute>>,
}
pub enum Msg {
ListAttributesResponse(Result<ResponseData>),
OnAttributeDeleted(String),
OnError(Error),
}
impl CommonComponent<GroupSchemaTable> for GroupSchemaTable {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListAttributesResponse(schema) => {
self.attributes =
Some(schema?.schema.group_schema.attributes.into_iter().collect());
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnAttributeDeleted(attribute_name) => {
match self.attributes {
None => {
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
Err(anyhow!("invalid state"))
}
Some(_) => {
self.attributes
.as_mut()
.unwrap()
.retain(|a| a.name != attribute_name);
Ok(true)
}
}
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for GroupSchemaTable {
type Message = Msg;
type Properties = Props;
fn create(ctx: &Context<Self>) -> Self {
let mut table = GroupSchemaTable {
common: CommonComponentParts::<Self>::create(),
attributes: None,
};
table.common.call_graphql::<GetGroupAttributesSchema, _>(
ctx,
get_group_attributes_schema::Variables {},
Msg::ListAttributesResponse,
"Error trying to fetch group schema",
);
table
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div>
{self.view_attributes(ctx)}
{self.view_errors()}
</div>
}
}
}
impl GroupSchemaTable {
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
let hardcoded = ctx.props().hardcoded;
let make_table = |attributes: &Vec<Attribute>| {
html! {
<div class="table-responsive">
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
<table class="table table-hover">
<thead>
<tr>
<th>{"Attribute name"}</th>
<th>{"Type"}</th>
<th>{"Visible"}</th>
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
</tr>
</thead>
<tbody>
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
</tbody>
</table>
</div>
}
};
match &self.attributes {
None => html! {{"Loading..."}},
Some(attributes) => {
let mut attributes = attributes.clone();
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
make_table(&attributes)
}
}
}
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
let link = ctx.link();
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
let checkmark = html! {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
</svg>
};
let hardcoded = ctx.props().hardcoded;
html! {
<tr key={attribute.name.clone()}>
<td>{&attribute.name}</td>
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
{
if hardcoded {
html!{}
} else {
html!{
<td>
<DeleteGroupAttribute
attribute_name={attribute.name.clone()}
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
on_error={link.callback(Msg::OnError)}/>
</td>
}
}
}
</tr>
}
}
fn view_errors(&self) -> Html {
match &self.common.error {
None => html! {},
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
}
}
}
#[function_component(ListGroupSchema)]
pub fn list_group_schema() -> Html {
html! {
<div>
<GroupSchemaTable hardcoded={true} />
<GroupSchemaTable hardcoded={false} />
<Link classes="btn btn-primary" to={AppRoute::CreateGroupAttribute}>
<i class="bi-plus-circle me-2"></i>
{"Create an attribute"}
</Link>
</div>
}
}

View File

@@ -1,5 +1,8 @@
use crate::{
components::router::{AppRoute, Link},
components::{
form::submit::Submit,
router::{AppRoute, Link},
},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@@ -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>
}
}

View File

@@ -1,12 +1,21 @@
pub mod add_group_member;
pub mod add_user_to_group;
pub mod app;
pub mod avatar;
pub mod banner;
pub mod change_password;
pub mod create_group;
pub mod create_group_attribute;
pub mod create_user;
pub mod create_user_attribute;
pub mod delete_group;
pub mod delete_group_attribute;
pub mod delete_user;
pub mod delete_user_attribute;
pub mod form;
pub mod group_details;
pub mod group_details_form;
pub mod group_schema_table;
pub mod group_table;
pub mod login;
pub mod logout;
@@ -17,4 +26,5 @@ pub mod router;
pub mod select;
pub mod user_details;
pub mod user_details_form;
pub mod user_schema_table;
pub mod user_table;

View File

@@ -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! {

View File

@@ -1,5 +1,8 @@
use crate::{
components::router::{AppRoute, Link},
components::{
form::{field::Field, submit::Submit},
router::{AppRoute, Link},
},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@@ -164,61 +167,29 @@ impl Component for ResetPasswordStep2Form {
}
_ => (),
};
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! {

View File

@@ -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,
}

View File

@@ -5,7 +5,11 @@ use crate::{
router::{AppRoute, Link},
user_details_form::UserDetailsForm,
},
infra::common_component::{CommonComponent, CommonComponentParts},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::GraphQlAttributeSchema,
},
};
use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery;
@@ -22,12 +26,34 @@ pub struct GetUserDetails;
pub type User = get_user_details::GetUserDetailsUser;
pub type Group = get_user_details::GetUserDetailsUserGroups;
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
pub type AttributeType = get_user_details::AttributeType;
convert_attribute_type!(AttributeType);
impl From<&AttributeSchema> for GraphQlAttributeSchema {
fn from(attr: &AttributeSchema) -> Self {
Self {
name: attr.name.clone(),
is_list: attr.is_list,
is_readonly: attr.is_readonly,
is_editable: attr.is_editable,
}
}
}
pub struct UserDetails {
common: CommonComponentParts<Self>,
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
/// received the server response yet.
user: Option<User>,
user_and_schema: Option<(User, Vec<AttributeSchema>)>,
}
impl UserDetails {
fn mut_groups(&mut self) -> &mut Vec<Group> {
&mut self.user_and_schema.as_mut().unwrap().0.groups
}
}
/// State machine describing the possible transitions of the component state.
@@ -50,22 +76,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 +202,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 +213,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 +229,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>},
}
}
}

View File

@@ -1,53 +1,21 @@
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::{read_all_form_attributes, AttributeValue, EmailIsRequired, IsAdmin},
schema::AttributeType,
},
};
use anyhow::{Ok, Result};
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_default()
}
}
impl FromStr for JsFile {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
if s.is_empty() {
Ok(JsFile::default())
} else {
bail!("Building file from non-empty string")
}
}
}
/// The fields of the form, with the editable details and the constraints.
#[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)]
@@ -63,26 +31,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>,
// None means that the avatar hasn't changed.
avatar: Option<JsFile>,
reader: Option<FileReader>,
/// True if we just successfully updated the user, to display a success message.
just_updated: bool,
user: User,
form_ref: NodeRef,
}
pub enum Msg {
/// A form field changed.
Update,
/// A new file was selected.
FileSelected(File),
/// The "Submit" button was clicked.
SubmitClicked,
/// The "Clear" button for the avatar was clicked.
ClearAvatarClicked,
/// A picked file finished loading.
FileLoaded(String, Result<Vec<u8>>),
/// We got the response from the server about our update message.
UserUpdated(Result<update_user::ResponseData>),
}
@@ -91,6 +50,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,53 +63,12 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::FileSelected(new_avatar) => {
if self
.avatar
.as_ref()
.and_then(|f| f.file.as_ref().map(|f| f.name()))
!= Some(new_avatar.name())
{
let file_name = new_avatar.name();
let link = ctx.link().clone();
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
link.send_message(Msg::FileLoaded(
file_name,
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
))
}));
self.avatar = Some(JsFile {
file: Some(new_avatar),
contents: None,
});
}
Ok(true)
}
Msg::SubmitClicked => self.submit_user_update_form(ctx),
Msg::ClearAvatarClicked => {
self.avatar = Some(JsFile::default());
Msg::UserUpdated(Err(e)) => Err(e),
Msg::UserUpdated(Result::Ok(_)) => {
self.just_updated = true;
Ok(true)
}
Msg::UserUpdated(response) => self.user_update_finished(response),
Msg::FileLoaded(file_name, data) => {
if let Some(avatar) = &mut self.avatar {
if let Some(file) = &avatar.file {
if file.name() == file_name {
let data = data?;
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = None;
bail!("Chosen image is not a valid JPEG");
} else {
avatar.contents = Some(data);
return Ok(true);
}
}
}
}
self.reader = None;
Ok(false)
}
}
}
@@ -161,19 +82,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: None,
just_updated: false,
reader: None,
user: ctx.props().user.clone(),
form_ref: NodeRef::default(),
}
}
@@ -183,173 +96,47 @@ impl Component for UserDetailsForm {
}
fn view(&self, ctx: &Context<Self>) -> Html {
type Field = yew_form::Field<UserModel>;
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()
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)
}
None => self.user.avatar.as_deref().unwrap_or("").to_owned(),
};
html! {
<div class="py-3">
<form class="form">
<div class="form-group row mb-3">
<label for="userId"
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-5">
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Self::upload_files(input.files())
})} />
</div>
<div class="col-3">
<button
class="btn btn-secondary col-auto"
id="avatarClear"
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
{"Clear"}
</button>
</div>
<div class="col-4">
{
if !avatar_string.is_empty() {
html!{
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", avatar_string)}
style="max-height:128px;max-width:128px;height:auto;width:auto;"
alt="Avatar" />
}
} else { html! {} }
}
</div>
</div>
</div>
</div>
<div class="form-group row 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 {
@@ -368,19 +155,97 @@ 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={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
values={values}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
value={values.first().cloned().unwrap_or_default()}
/>
}
}
}
fn get_custom_attribute_static(
attribute_schema: &AttributeSchema,
user_attributes: &[Attribute],
) -> Html {
let values = user_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
html! {
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
</StaticValue>
}
}
impl UserDetailsForm {
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
if !self.form.validate() {
bail!("Invalid inputs");
}
if let Some(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,
@@ -392,23 +257,8 @@ impl UserDetailsForm {
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);
}
if let Some(avatar) = &self.avatar {
user_input.avatar = Some(to_base64(avatar)?);
}
user_input.removeAttributes = remove_attributes;
user_input.insertAttributes = insert_attributes;
// Nothing changed.
if user_input == default_user_input {
return Ok(false);
@@ -422,58 +272,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) = &self.avatar {
self.user.avatar = Some(to_base64(avatar)?);
}
self.just_updated = true;
Ok(true)
}
fn upload_files(files: Option<FileList>) -> Msg {
if let Some(files) = files {
if files.length() > 0 {
Msg::FileSelected(File::from(files.item(0).unwrap()))
} else {
Msg::Update
}
} else {
Msg::Update
}
}
}
fn is_valid_jpeg(bytes: &[u8]) -> bool {
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()
.is_ok()
}
fn to_base64(file: &JsFile) -> Result<String> {
match file {
JsFile {
file: None,
contents: _,
} => Ok(String::new()),
JsFile {
file: Some(_),
contents: None,
} => bail!("Image file hasn't finished loading, try again"),
JsFile {
file: Some(_),
contents: Some(data),
} => {
if !is_valid_jpeg(data.as_slice()) {
bail!("Chosen image is not a valid JPEG");
}
Ok(base64::encode(data))
}
}
}

View File

@@ -0,0 +1,198 @@
use crate::{
components::{
delete_user_attribute::DeleteUserAttribute,
router::{AppRoute, Link},
},
convert_attribute_type,
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::AttributeType,
},
};
use anyhow::{anyhow, Error, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_user_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetUserAttributesSchema;
use get_user_attributes_schema::ResponseData;
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
convert_attribute_type!(get_user_attributes_schema::AttributeType);
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub hardcoded: bool,
}
pub struct UserSchemaTable {
common: CommonComponentParts<Self>,
attributes: Option<Vec<Attribute>>,
}
pub enum Msg {
ListAttributesResponse(Result<ResponseData>),
OnAttributeDeleted(String),
OnError(Error),
}
impl CommonComponent<UserSchemaTable> for UserSchemaTable {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListAttributesResponse(schema) => {
self.attributes = Some(schema?.schema.user_schema.attributes.into_iter().collect());
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnAttributeDeleted(attribute_name) => {
match self.attributes {
None => {
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
Err(anyhow!("invalid state"))
}
Some(_) => {
self.attributes
.as_mut()
.unwrap()
.retain(|a| a.name != attribute_name);
Ok(true)
}
}
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for UserSchemaTable {
type Message = Msg;
type Properties = Props;
fn create(ctx: &Context<Self>) -> Self {
let mut table = UserSchemaTable {
common: CommonComponentParts::<Self>::create(),
attributes: None,
};
table.common.call_graphql::<GetUserAttributesSchema, _>(
ctx,
get_user_attributes_schema::Variables {},
Msg::ListAttributesResponse,
"Error trying to fetch user schema",
);
table
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div>
{self.view_attributes(ctx)}
{self.view_errors()}
</div>
}
}
}
impl UserSchemaTable {
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
let hardcoded = ctx.props().hardcoded;
let make_table = |attributes: &Vec<Attribute>| {
html! {
<div class="table-responsive">
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
<table class="table table-hover">
<thead>
<tr>
<th>{"Attribute name"}</th>
<th>{"Type"}</th>
<th>{"Editable"}</th>
<th>{"Visible"}</th>
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
</tr>
</thead>
<tbody>
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
</tbody>
</table>
</div>
}
};
match &self.attributes {
None => html! {{"Loading..."}},
Some(attributes) => {
let mut attributes = attributes.clone();
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
make_table(&attributes)
}
}
}
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
let link = ctx.link();
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
let checkmark = html! {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
</svg>
};
let hardcoded = ctx.props().hardcoded;
html! {
<tr key={attribute.name.clone()}>
<td>{&attribute.name}</td>
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
{
if hardcoded {
html!{}
} else {
html!{
<td>
<DeleteUserAttribute
attribute_name={attribute.name.clone()}
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
on_error={link.callback(Msg::OnError)}/>
</td>
}
}
}
</tr>
}
}
fn view_errors(&self) -> Html {
match &self.common.error {
None => html! {},
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
}
}
}
#[function_component(ListUserSchema)]
pub fn list_user_schema() -> Html {
html! {
<div>
<UserSchemaTable hardcoded={true} />
<UserSchemaTable hardcoded={false} />
<Link classes="btn btn-primary" to={AppRoute::CreateUserAttribute}>
<i class="bi-plus-circle me-2"></i>
{"Create an attribute"}
</Link>
</div>
}
}

View File

@@ -1,6 +1,6 @@
use super::cookies::set_cookie;
use anyhow::{anyhow, Context, Result};
use gloo_net::http::{Method, Request};
use gloo_net::http::{Method, RequestBuilder};
use graphql_client::GraphQLQuery;
use lldap_auth::{login, registration, JWTClaims};
@@ -16,25 +16,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),
}
const GET_REQUEST: RequestType<()> = RequestType::Get;
fn base_url() -> String {
yew_router::utils::base_url().unwrap_or_default()
}
async fn call_server(
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?)
@@ -51,7 +58,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
@@ -63,7 +70,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(|_| ())
@@ -102,7 +109,7 @@ impl HostService {
let request_body = QueryType::build_query(variables);
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
&(base_url() + "/api/graphql"),
Some(request_body),
RequestType::Post(request_body),
error_message,
)
.await
@@ -114,7 +121,7 @@ impl HostService {
) -> Result<Box<login::ServerLoginStartResponse>> {
call_server_json_with_error_message(
&(base_url() + "/auth/opaque/login/start"),
Some(request),
RequestType::Post(request),
"Could not start authentication: ",
)
.await
@@ -123,7 +130,7 @@ impl HostService {
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/opaque/login/finish"),
Some(request),
RequestType::Post(request),
"Could not finish authentication",
)
.await
@@ -135,7 +142,7 @@ impl HostService {
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
call_server_json_with_error_message(
&(base_url() + "/auth/opaque/register/start"),
Some(request),
RequestType::Post(request),
"Could not start registration: ",
)
.await
@@ -146,7 +153,7 @@ impl HostService {
) -> Result<()> {
call_server_empty_response_with_error_message(
&(base_url() + "/auth/opaque/register/finish"),
Some(request),
RequestType::Post(request),
"Could not finish registration",
)
.await
@@ -155,7 +162,7 @@ impl HostService {
pub async fn refresh() -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/refresh"),
NO_BODY,
GET_REQUEST,
"Could not start authentication: ",
)
.await
@@ -166,7 +173,7 @@ impl HostService {
pub async fn logout() -> Result<()> {
call_server_empty_response_with_error_message(
&(base_url() + "/auth/logout"),
NO_BODY,
GET_REQUEST,
"Could not logout",
)
.await
@@ -179,7 +186,7 @@ impl HostService {
base_url(),
url_escape::encode_query(&username)
),
NO_BODY,
RequestType::Post(""),
"Could not initiate password reset",
)
.await
@@ -190,14 +197,14 @@ impl HostService {
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
call_server_json_with_error_message(
&format!("{}/auth/reset/step2/{}", base_url(), token),
NO_BODY,
GET_REQUEST,
"Could not validate token",
)
.await
}
pub async fn probe_password_reset() -> Result<bool> {
Ok(gloo_net::http::Request::get(
Ok(gloo_net::http::Request::post(
&(base_url() + "/auth/reset/step1/lldap_unlikely_very_long_user_name"),
)
.header("Content-Type", "application/json")

View File

@@ -0,0 +1,70 @@
use anyhow::{anyhow, ensure, Result};
use validator::validate_email;
use web_sys::{FormData, HtmlFormElement};
use yew::NodeRef;
#[derive(Debug)]
pub struct AttributeValue {
pub name: String,
pub values: Vec<String>,
}
pub struct GraphQlAttributeSchema {
pub name: String,
pub is_list: bool,
pub is_readonly: bool,
pub is_editable: bool,
}
fn validate_attributes(
all_values: &[AttributeValue],
email_is_required: EmailIsRequired,
) -> Result<()> {
let maybe_email_values = all_values.iter().find(|a| a.name == "mail");
if email_is_required.0 || maybe_email_values.is_some() {
let email_values = &maybe_email_values
.ok_or_else(|| anyhow!("Email is required"))?
.values;
ensure!(email_values.len() == 1, "Email is required");
ensure!(validate_email(&email_values[0]), "Email is not valid");
}
Ok(())
}
pub struct IsAdmin(pub bool);
pub struct EmailIsRequired(pub bool);
pub fn read_all_form_attributes(
schema: impl IntoIterator<Item = impl Into<GraphQlAttributeSchema>>,
form_ref: &NodeRef,
is_admin: IsAdmin,
email_is_required: EmailIsRequired,
) -> Result<Vec<AttributeValue>> {
let form = form_ref.cast::<HtmlFormElement>().unwrap();
let form_data = FormData::new_with_form(&form)
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
let all_values = schema
.into_iter()
.map(Into::<GraphQlAttributeSchema>::into)
.filter(|attr| !attr.is_readonly && (is_admin.0 || attr.is_editable))
.map(|attr| -> Result<AttributeValue> {
let val = form_data
.get_all(attr.name.as_str())
.iter()
.map(|js_val| js_val.as_string().unwrap_or_default())
.filter(|val| !val.is_empty())
.collect::<Vec<String>>();
ensure!(
val.len() <= 1 || attr.is_list,
"Multiple values supplied for non-list attribute {}",
attr.name
);
Ok(AttributeValue {
name: attr.name.clone(),
values: val,
})
})
.collect::<Result<Vec<_>>>()?;
validate_attributes(&all_values, email_is_required)?;
Ok(all_values)
}

View File

@@ -0,0 +1,59 @@
use crate::infra::api::HostService;
use anyhow::Result;
use graphql_client::GraphQLQuery;
use wasm_bindgen_futures::spawn_local;
use yew::{use_effect_with_deps, use_state_eq, UseStateHandle};
// Enum to represent a result that is fetched asynchronously.
#[derive(Debug)]
pub enum LoadableResult<T> {
// The result is still being fetched
Loading,
// The async call is completed
Loaded(Result<T>),
}
impl<T: PartialEq> PartialEq for LoadableResult<T> {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(LoadableResult::Loading, LoadableResult::Loading) => true,
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
e1.to_string().eq(&e2.to_string())
}
_ => false,
}
}
}
pub fn use_graphql_call<QueryType>(
variables: QueryType::Variables,
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
where
QueryType: GraphQLQuery + 'static,
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
{
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
use_state_eq(|| LoadableResult::Loading);
{
let loadable_result = loadable_result.clone();
use_effect_with_deps(
move |variables| {
let task = HostService::graphql_query::<QueryType>(
variables.clone(),
"Failed graphql query",
);
spawn_local(async move {
let response = task.await;
loadable_result.set(LoadableResult::Loaded(response));
});
|| ()
},
variables,
)
}
loadable_result.clone()
}

View File

@@ -1,5 +1,9 @@
pub mod api;
pub mod common_component;
pub mod cookies;
pub mod form_utils;
pub mod functional;
pub mod graphql;
pub mod modal;
pub mod schema;
pub mod tooltip;

View File

@@ -1,3 +1,5 @@
#![allow(clippy::empty_docs)]
use wasm_bindgen::prelude::*;
#[wasm_bindgen]

66
app/src/infra/schema.rs Normal file
View File

@@ -0,0 +1,66 @@
use anyhow::Result;
use std::{fmt::Display, str::FromStr};
use validator::ValidationError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttributeType {
String,
Integer,
DateTime,
Jpeg,
}
impl Display for AttributeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl FromStr for AttributeType {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"String" => Ok(AttributeType::String),
"Integer" => Ok(AttributeType::Integer),
"DateTime" => Ok(AttributeType::DateTime),
"Jpeg" => Ok(AttributeType::Jpeg),
_ => Err(()),
}
}
}
// Macro to generate traits for converting between AttributeType and the
// graphql generated equivalents.
#[macro_export]
macro_rules! convert_attribute_type {
($source_type:ty) => {
impl From<$source_type> for $crate::infra::schema::AttributeType {
fn from(value: $source_type) -> Self {
match value {
<$source_type>::STRING => $crate::infra::schema::AttributeType::String,
<$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
<$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
<$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
_ => panic!("Unknown attribute type"),
}
}
}
impl From<$crate::infra::schema::AttributeType> for $source_type {
fn from(value: $crate::infra::schema::AttributeType) -> Self {
match value {
$crate::infra::schema::AttributeType::String => <$source_type>::STRING,
$crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
$crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
$crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
}
}
}
};
}
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
AttributeType::from_str(attribute_type)
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
Ok(())
}

12
app/src/infra/tooltip.rs Normal file
View File

@@ -0,0 +1,12 @@
#![allow(clippy::empty_docs)]
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = bootstrap)]
pub type Tooltip;
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
pub fn new(e: web_sys::Element) -> Tooltip;
}

View File

@@ -6,7 +6,7 @@ homepage = "https://github.com/lldap/lldap"
license = "GPL-3.0-only"
name = "lldap_auth"
repository = "https://github.com/lldap/lldap"
version = "0.4.0"
version = "0.6.0"
[features]
default = ["opaque_server", "opaque_client"]
@@ -25,15 +25,20 @@ serde = "*"
sha2 = "0.9"
thiserror = "*"
[dependencies.derive_more]
features = ["debug", "display"]
default-features = false
version = "1"
[dependencies.opaque-ke]
version = "0.6"
version = "0.7"
[dependencies.chrono]
version = "*"
features = [ "serde" ]
features = ["serde"]
[dependencies.sea-orm]
version= "0.12"
version = "0.12"
default-features = false
features = ["macros"]
optional = true

View File

@@ -108,7 +108,7 @@ pub mod types {
use serde::{Deserialize, Serialize};
#[cfg(feature = "sea_orm")]
use sea_orm::{DbErr, DeriveValueType, QueryResult, TryFromU64, Value};
use sea_orm::{DbErr, DeriveValueType, TryFromU64, Value};
#[derive(
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
@@ -151,10 +151,22 @@ pub mod types {
}
#[derive(
PartialEq, Eq, PartialOrd, Ord, Clone, Debug, Default, Hash, Serialize, Deserialize,
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 {
@@ -176,11 +188,6 @@ pub mod types {
Self(s.into())
}
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.0.as_str())
}
}
#[cfg(feature = "sea_orm")]
impl From<&UserId> for Value {

View File

@@ -14,15 +14,14 @@ Backend:
is defined in `schema.graphql`.
* The static frontend files are served by this port too.
Note that secure protocols (LDAPS, HTTPS) are currently not supported. This can
be worked around by using a reverse proxy in front of the server (for the HTTP
API) that wraps/unwraps the HTTPS messages, or only open the service to
localhost or other trusted docker containers (for the LDAP API).
Note that HTTPS is currently not supported. This can be worked around by using
a reverse proxy in front of the server (for the HTTP API) that wraps/unwraps
the HTTPS messages. LDAPS is supported.
Frontend:
* User management UI.
* Written in Rust compiled to WASM as an SPA with the Yew library.
* Based on components, with a React-like organization.
* Based on components, with a React-like framework.
Data storage:
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
@@ -50,19 +49,19 @@ Data storage:
Authentication is done via the OPAQUE protocol, meaning that the passwords are
never sent to the server, but instead the client proves that they know the
correct password (zero-knowledge proof). This is likely overkill, especially
considered that the LDAP interface requires sending the password to the server,
but it's one less potential flaw (especially since the LDAP interface can be
restricted to an internal docker-only network while the web app is exposed to
the Internet).
considered that the LDAP interface requires sending the password in cleartext
to the server, but it's one less potential flaw (especially since the LDAP
interface can be restricted to an internal docker-only network while the web
app is exposed to the Internet).
OPAQUE's "passwords" (user-specific blobs of data that can only be used in a
zero-knowledge proof that the password is correct) are hashed using Argon2, the
state of the art in terms of password storage. They are hashed using a secret
provided in the configuration (which can be given as environment variable or
command line argument as well): this should be kept secret and shouldn't change
(it would invalidate all passwords). Note that even if it was compromised, the
attacker wouldn't be able to decrypt the passwords without running an expensive
brute-force search independently for each password.
provided in the configuration (which can be given as environment variable,
command line argument or a file as well): this should be kept secret and
shouldn't change (it would invalidate all passwords). Note that even if it was
compromised, the attacker wouldn't be able to decrypt the passwords without
running an expensive brute-force search independently for each password.
### JWTs and refresh tokens

View File

@@ -0,0 +1,48 @@
# MegaRAC SP-X BMC IPMI LDAP Setup
The MegaRAC SP-X BMC is a service processor firmware stack designed by American Megatrends Inc. (AMI), aimed at providing out-of-band management for servers and computing systems.
It's part of the MegaRAC family of management solutions, offering remote server management capabilities, including monitoring, control, and maintenance functionalities, independent of the operating system or system state.
This enables administrators to manage systems remotely for tasks such as updates, troubleshooting, and recovery.
## Setting up LLDAP with MegaRAC SP-X BMC IPMI
### Pre-requisites
- Create and assign the `ipmi` group in LLDAP to a (test) user.
- Bind User: It is recommended that you create a separate user account (e.g, `bind_user`) instead of admin for sharing Bind credentials with other services. The bind_user should be a member of the lldap_strict_readonly group to limit access to your LDAP configuration in LLDAP.
- Bind Password: password of the user specified above
### Configuration Steps
1. **Navigate**: Go to `Settings > External User Settings > LDAP/E-Directory Settings > General Settings`.
2. **General LDAP Settings**:
- **Encryption Type**: `SSL` (or No Encryption if preferred)
- **Common Name Type**: `FQDN` (or IP if you use a plain IP address to connect to lldap)
- **Server Address**: `fqdn.lldap.tld`
- **Port**: `6360` (default for SSL, adjust if necessary to default non ssl `3890`)
3. **Authentication** (use read-only bind user):
- **Bind DN**: `uid=bind_user,ou=people,dc=example,dc=com`
- **Password**: `change_bind_user_password`
4. **Search Configuration**:
- **Search Base**: `ou=people,dc=example,dc=com`
- **Attribute of User Login**: `uid`
![General LDAP Settings](images/megarac_user.png)
5. **Navigate**: Go to `Settings > External User Settings > LDAP/E-Directory Settings > Role groups`.
6. **Click on empty role group in order to assign a new one**
7. **Role Group - Group Details**:
- **Group Name**: `ipmi`
- **Group Domain**: `cn=ipmi,ou=groups,dc=example,dc=com`
- **Group Privilege**: `Administrator`
8. **Group Permissions**:
- KVM Access: Enabled (adjust as needed)
- VMedia Access: Enabled (adjust as needed)
![Role Groups](images/megarac_group.png)

View File

@@ -15,7 +15,7 @@ authentication_backend:
implementation: custom
# Pattern is ldap://HOSTNAME-OR-IP:PORT
# Normal ldap port is 389, standard in LLDAP is 3890
url: ldap://lldap:3890
address: ldap://lldap:3890
# The dial timeout for LDAP.
timeout: 5s
# Use StartTLS with the LDAP connection, TLS not supported right now
@@ -25,7 +25,6 @@ authentication_backend:
# minimum_version: TLS1.2
# Set base dn, like dc=google,dc.com
base_dn: dc=example,dc=com
username_attribute: uid
# You need to set this to ou=people, because all users are stored in this ou!
additional_users_dn: ou=people
# To allow sign in both with username and email, one can use a filter like
@@ -36,13 +35,17 @@ authentication_backend:
# The groups are not displayed in the UI, but this filter works.
groups_filter: "(member={dn})"
# The attribute holding the name of the group.
group_name_attribute: cn
# Email attribute
mail_attribute: mail
# The attribute holding the display name of the user. This will be used to greet an authenticated user.
display_name_attribute: displayName
# The username and password of the admin user.
# "admin" should be the admin username you set in the LLDAP configuration
user: uid=admin,ou=people,dc=example,dc=com
attributes:
display_name: displayName
username: uid
group_name: cn
mail: mail
# distinguished_name: distinguishedName
# member_of: memberOf
# The username and password of the bind user.
# "bind_user" should be the username you created for authentication with the "lldap_strict_readonly" permission. It is not recommended to use an actual admin account here.
# If you are configuring Authelia to change user passwords, then the account used here needs the "lldap_password_manager" permission instead.
user: uid=bind_user,ou=people,dc=example,dc=com
# Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
password: 'REPLACE_ME'

View File

@@ -1,4 +1,4 @@
# Bootstrapping lldap using [bootstrap.sh](bootstrap.sh) script
# Bootstrapping lldap using [bootstrap.sh](/scripts/bootstrap.sh) script
bootstrap.sh allows managing your lldap in a git-ops, declarative way using JSON config files.
@@ -12,7 +12,7 @@ The script can:
* create groups
* delete redundant users and groups (when `DO_CLEANUP` env var is true)
* maintain the desired state described in JSON config files
* create user/group user-defined attributes
![](bootstrap-example-log-1.jpeg)
@@ -27,11 +27,13 @@ The script can:
## Environment variables
- `LLDAP_URL` or `LLDAP_URL_FILE` - URL to your lldap instance or path to file that contains URL (**MANDATORY**)
- `LLDAP_ADMIN_USERNAME` or `LLDAP_ADMIN_USERNAME_FILE` - admin username or path to file that contains username (**MANDATORY**)
- `LLDAP_ADMIN_PASSWORD` or `LLDAP_ADMIN_PASSWORD_FILE` - admin password or path to file that contains password (**MANDATORY**)
- `USER_CONFIGS_DIR` (default value: `/user-configs`) - directory where the user JSON configs could be found
- `GROUP_CONFIGS_DIR` (default value: `/group-configs`) - directory where the group JSON configs could be found
- `LLDAP_URL` or `LLDAP_URL_FILE` (default value: `http://localhost:17170`) - URL to your lldap instance or path to file that contains URL
- `LLDAP_ADMIN_USERNAME` or `LLDAP_ADMIN_USERNAME_FILE` (default value: `admin`) - admin username or path to file that contains username
- `LLDAP_ADMIN_PASSWORD` or `LLDAP_ADMIN_PASSWORD_FILE` (default value: `password`) - admin password or path to file that contains password
- `USER_CONFIGS_DIR` (default value: `/bootstrap/user-configs`) - directory where the user JSON configs could be found
- `GROUP_CONFIGS_DIR` (default value: `/bootstrap/group-configs`) - directory where the group JSON configs could be found
- `USER_SCHEMAS_DIR` (default value: `/bootstrap/user-schemas`) - directory where the user schema JSON configs could be found
- `GROUP_SCHEMAS_DIR` (default value: `/bootstrap/group-schemas`) - directory where the group schema JSON configs could be found
- `LLDAP_SET_PASSWORD_PATH` - path to the `lldap_set_password` utility (default value: `/app/lldap_set_password`)
- `DO_CLEANUP` (default value: `false`) - delete groups and users not specified in config files, also remove users from groups that they do not belong to
@@ -96,6 +98,44 @@ Fields description:
```
### User and group schema config file example
User and group schema have the same structure.
Fields description:
* `name`: name of field, case insensitve - you should use lowercase
* `attributeType`: `STRING` / `INTEGER` / `JPEG` / `DATE_TIME`
* `isList`: single on multiple value field
* `isEditable`: self-explanatory
* `isVisible`: self-explanatory
```json
[
{
"name": "uid",
"attributeType": "INTEGER",
"isEditable": false,
"isList": false,
"isVisible": true
},
{
"name": "mailbox",
"attributeType": "STRING",
"isEditable": false,
"isList": false,
"isVisible": true
},
{
"name": "mail_alias",
"attributeType": "STRING",
"isEditable": false,
"isList": true,
"isVisible": true
}
]
```
## Usage example
### Manually
@@ -110,11 +150,21 @@ export LLDAP_ADMIN_USERNAME=admin
export LLDAP_ADMIN_PASSWORD=changeme
export USER_CONFIGS_DIR="$(realpath ./configs/user)"
export GROUP_CONFIGS_DIR="$(realpath ./configs/group)"
export USER_SCHEMAS_DIR="$(realpath ./configs/user-schema)"
export GROUP_SCHEMAS_DIR="$(realpath ./configs/group-schema)"
export LLDAP_SET_PASSWORD_PATH="$(realpath ./lldap_set_password)"
export DO_CLEANUP=false
./bootstrap.sh
```
### Manually from running docker container or service
After setting a docker container you can bootstrap users using:
```
docker exec -e LLDAP_ADMIN_PASSWORD_FILE=password -v ./bootstrap:/bootstrap -it $(docker ps --filter name=lldap -q) /app/bootstrap.sh
```
### Docker compose
Let's suppose you have the next file structure:
@@ -129,10 +179,17 @@ Let's suppose you have the next file structure:
│ ├─ ...
│ └─ user-n.json
└─ group-configs
├─ group-1.json
| ├─ group-1.json
| ├─ ...
| └─ group-n.json
└─ user-schemas
| ├─ user-attrs-1.json
| ├─ ...
| └─ user-attrs-n.json
└─ group-schemas
├─ group-attrs-1.json
├─ ...
└─ group-n.json
└─ group-attrs-n.json
```
You should mount `bootstrap` dir to lldap container and set the corresponding `env` variables:
@@ -160,6 +217,8 @@ services:
- LLDAP_ADMIN_PASSWORD=changeme # same as LLDAP_LDAP_USER_PASS
- USER_CONFIGS_DIR=/bootstrap/user-configs
- GROUP_CONFIGS_DIR=/bootstrap/group-configs
- USER_SCHEMAS_DIR=/bootstrap/user-schemas
- GROUP_SCHEMAS_DIR=/bootstrap/group-schemas
- DO_CLEANUP=false
```
@@ -205,14 +264,15 @@ spec:
volumeMounts:
- name: bootstrap
mountPath: /bootstrap/bootstrap.sh
readOnly: true
subPath: bootstrap.sh
- name: user-configs
mountPath: /user-configs
mountPath: /bootstrap/user-configs
readOnly: true
- name: group-configs
mountPath: /group-configs
mountPath: /bootstrap/group-configs
readOnly: true
volumes:

59
example_configs/carpal.md Normal file
View File

@@ -0,0 +1,59 @@
# Configuration for Carpal
[Carpal](https://github.com/peeley/carpal) is a small, configurable
[WebFinger](https://webfinger.net) server than can pull resource information
from LDAP directories.
There are two files used to configure Carpal for LDAP:
- The YAML configuration file for Carpal itself
- A Go template file for injecting the LDAP data into the WebFinger response
### YAML File
Replace the server URL, admin credentials, and domain for your server:
```yaml
# /etc/carpal/config.yml
driver: ldap
ldap:
url: ldap://myldapserver
bind_user: uid=myadmin,ou=people,dc=foobar,dc=com
bind_pass: myadminpassword
basedn: ou=people,dc=foobar,dc=com
filter: (uid=*)
user_attr: uid
attributes:
- uid
- mail
- cn
template: /etc/carpal/ldap.gotempl
```
If you have configured any user-defined attributes on your users, you can also
add those to the `attributes` field.
### Go Template File
This is an example template; the template file is intended to be editable for
your needs. If your users, for example, don't have Mastodon profiles, you can
delete the Mastodon alias.
```gotempl
# /etc/carpal/ldap.gotempl
aliases:
- "mailto:{{ index . "mail" }}"
- "https://mastodon/{{ index . "uid" }}"
properties:
'http://webfinger.example/ns/name': '{{ index . "cn" }}'
links:
- rel: "http://webfinger.example/rel/profile-page"
href: "https://www.example.com/~{{ index . "uid" }}/"
```
This example also only contains the default attributes present on all LLDAP
users. If you have added custom user-defined attributes to your users and added
them to the `attributes` field of the YAML config file, you can use them in
this template file.

View File

@@ -10,8 +10,7 @@ connectors:
id: ldap
name: LDAP
config:
host: lldap-host # make sure it does not start with `ldap://`
port: 3890 # or 6360 if you have ldaps enabled
host: lldap-host:3890 # or 6360 if you have ldaps enabled, make sure it does not start with `ldap://`
insecureNoSSL: true # or false if you have ldaps enabled
insecureSkipVerify: true # or false if you have ldaps enabled
bindDN: uid=admin,ou=people,dc=example,dc=com # replace admin with your admin user

View File

@@ -9,7 +9,7 @@ $conf['authtype'] = 'authldap'; //enable this Auth plugin
$conf['superuser'] = 'admin';
$conf['plugin']['authldap']['server'] = 'ldap://lldap_server:3890'; #IP of your lldap
$conf['plugin']['authldap']['usertree'] = 'ou=people,dc=example,dc=com';
$conf['plugin']['authldap']['grouptree'] = 'ou=groups, dc=example, dc=com';
$conf['plugin']['authldap']['grouptree'] = 'ou=groups,dc=example,dc=com';
$conf['plugin']['authldap']['userfilter'] = '(&(uid=%{user})(objectClass=person))';
$conf['plugin']['authldap']['groupfilter'] = '(&(member=%{dn})(objectClass=groupOfUniqueNames))';
$conf['plugin']['authldap']['attributes'] = array('cn', 'displayname', 'mail', 'givenname', 'objectclass', 'sn', 'uid', 'memberof');
@@ -24,3 +24,11 @@ All you need to do is to activate the plugin. This can be done on the DokuWiki E
Once the LDAP settings are defined, proceed to define the default authentication method.
Navigate to Table of Contents > DokuWiki > Authentication.
On the Authentication backend, select ```authldap``` and save the changes.
## Internal (or other authentication) fallback
If you dont want to use LDAP authentication exclusively, you can install the [authchained plugin](https://www.dokuwiki.org/plugin:authchained). It tries multiple auth backends when a user logs in.
```
$conf['authtype'] = 'authchained';
$conf['plugin']['authchained']['authtypes'] = 'authldap:authplain';
```

View File

@@ -0,0 +1,18 @@
Extract lldap's [FreeBSD tar.gz](https://github.com/n-connect/rustd-hbbx/blob/main/x86_64-freebsd_lldap-0.5.1.tar.gz) under /usr/local/:
`tar -xvf x86_64-freebsd_lldap-0.5.1.tar.gz -C /usr/local/`
Move rc.d script into the right place:
`mv /usr/local/lldap_server/rc.d_lldap /usr/local/etc/rc.d/lldap`
Make your config, if your want to enable LDAPS, copy your server key and certification files, and set the owneship (currently www):
`cp /usr/local/lldap_server/lldap_config.docker_template.toml /usr/local/lldap_server/lldap_config..toml`
Enable lldap service in /etc/rc.conf:
`sysrc lldap_enable="YES"`
Start your service:
`service lldap start`

View File

@@ -0,0 +1,27 @@
#!/bin/sh
# PROVIDE: lldap
# REQUIRE: DAEMON NETWORKING
# KEYWORD: shutdown
# Add the following lines to /etc/rc.conf to enable lldap:
# lldap_enable : set to "YES" to enable the daemon, default is "NO"
. /etc/rc.subr
name=lldap
rcvar=lldap_enable
lldap_chdir="/usr/local/lldap_server"
load_rc_config $name
lldap_enable=${lldap_enable:-"NO"}
logfile="/var/log/${name}.log"
procname=/usr/local/lldap_server/lldap
command="/usr/sbin/daemon"
command_args="-u www -o ${logfile} -t ${name} /usr/local/lldap_server/lldap run"
run_rc_command "$1"

View File

@@ -20,7 +20,7 @@ ssl_skip_verify = false
# client_key = "/path/to/client.key"
# Search user bind dn
bind_dn = "uid=<your grafana user>,ou=people,dc=example,dc=org"
bind_dn = "uid=<your grafana user>,ou=people,dc=example,dc=com"
# Search user bind password
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
bind_password = "<grafana user password>"
@@ -31,7 +31,7 @@ search_filter = "(uid=%s)"
# search_filter = "(&(uid=%s)(memberOf=cn=<your group>,ou=groups,dc=example,dc=org))"
# An array of base dns to search through
search_base_dns = ["dc=example,dc=org"]
search_base_dns = ["dc=example,dc=com"]
# Specify names of the LDAP attributes your LDAP uses
[servers.attributes]

31
example_configs/harbor.md Normal file
View File

@@ -0,0 +1,31 @@
[Harbor](https://goharbor.io) is a CNCF cloud native container registry for kubernetes.
You can pass environment variables into ``harbor-core`` for auth configuration as documented [here](https://github.com/goharbor/website/blob/release-2.10.0/docs/install-config/configure-system-settings-cli.md#harbor-configuration-items).
Configure ``ldap_url`` and ``ldap_verify_cert`` as needed for your installation.
Using the [harbor-helm](https://github.com/goharbor/harbor-helm) chart, these vars can be passed in under ``core.configureUserSettings`` as a JSON string:
```yaml
core:
configureUserSettings: |
{
"auth_mode": "ldap_auth",
"ldap_url": "ldaps://lldap.example.com",
"ldap_base_dn": "ou=people,dc=example,dc=com",
"ldap_search_dn": "uid=bind,ou=people,dc=example,dc=com",
"ldap_search_password": "very-secure-password",
"ldap_group_base_dn": "ou=groups,dc=example,dc=com",
"ldap_group_admin_dn": "cn=harbor-admin-group,ou=groups,dc=example,dc=com",
"ldap_group_search_filter": "(objectClass=groupOfUniqueNames)",
"ldap_group_attribute_name": "uid"
}
```
> [!IMPORTANT]
> ``ldap_search_dn`` needs to be able to bind and search. The ``lldap_strict_readonly`` group is sufficient.
> [!NOTE]
> Members of the ``ldap_group_admin_dn`` group will receive harbor admin privledges.
> Users outside this group will have their ldap group(s) imported into harbor (under "groups" with type "ldap").
> These groups can be used for permissions assigned to a harbor "project".

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,10 +1,12 @@
# Configuration for Jellyfin
Replace `dc=example,dc=com` with your LLDAP configured domain.
Replace all instances of `dc=example,dc=com` with your LLDAP configured domain.
## LDAP Server Settings
### LDAP Bind User
Create an ldap user for Jellyfin to run search queries (and optionally reset passwords). For example `jellyfin_bind_user`
```
uid=admin,ou=people,dc=example,dc=com
uid=jellyfin_bind_user,ou=people,dc=example,dc=com
```
### LDAP Base DN for searches
@@ -12,31 +14,30 @@ uid=admin,ou=people,dc=example,dc=com
ou=people,dc=example,dc=com
```
### LDAP Attributes
```
uid, mail
```
### LDAP Name Attribute
```
uid
```
### User Filter
## LDAP User Settings
### LDAP Search Filter
If you have a `media` group, you can use:
```
(memberof=cn=media,ou=groups,dc=example,dc=com)
```
Otherwise, just use:
```
(uid=*)
```
### Admin Base DN
### LDAP Search Attributes
```
uid, mail
```
### LDAP Uid Attribute
```
uid
```
### LDAP Username Attribute
```
uid
```
### LDAP Admin Base DN
The DN to search for your admins.
```
ou=people,dc=example,dc=com

View File

@@ -69,4 +69,4 @@ Since Keycloak and LLDAP use different attributes for different parts of a user'
Go back to "User Federation", edit your LDAP integration and click on the "Mappers" tab.
Find or create the "first name" mapper (it should have type `user-attribute-ldap-mapper`) and ensure the "LDAP Attribute" setting is set to `givenname`. Keycloak may have defaulted to `cn` which LLDAP uses for the "Display Name" of a user.
Find or create the "first name" mapper (it should have type `user-attribute-ldap-mapper`) and ensure the "LDAP Attribute" setting is set to `givenName`. Keycloak may have defaulted to `cn` which LLDAP uses for the "Display Name" of a user.

View File

@@ -54,14 +54,14 @@ services:
- ENABLE_OPENDMARC=0
# >>> Postfix LDAP Integration
- ACCOUNT_PROVISIONER=LDAP
- LDAP_SERVER_HOST=lldap:3890
- LDAP_SEARCH_BASE=dc=example,dc=com
- LDAP_SERVER_HOST=ldap://lldap:3890
- LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
- LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com
- LDAP_BIND_PW=adminpassword
- LDAP_QUERY_FILTER_USER=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
- LDAP_QUERY_FILTER_GROUP=(&(objectClass=groupOfUniqueNames)(uid=%s))
- LDAP_QUERY_FILTER_ALIAS=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
- LDAP_QUERY_FILTER_DOMAIN=((mail=*@%s))
- LDAP_QUERY_FILTER_DOMAIN=(mail=*@%s)
# <<< Postfix LDAP Integration
# >>> Dovecot LDAP Integration
- DOVECOT_AUTH_BIND=yes

View File

@@ -0,0 +1,79 @@
# Configuring LDAP in Metabase
[Metabase](https://github.com/metabase/metabase)
The simplest, fastest way to get business intelligence and analytics to everyone in your company 😋
---
## LDAP Host
```
example.com
```
## LDAP Port
```
3890
```
## LDAP Security
```
None
```
## Username or DN
It is recommended to use users belonging to the `lldap_strict_readonly` group
```
cn=adminro,ou=people,dc=example,dc=com
```
## Password
```
passwd
```
## User search base
```
ou=people,dc=example,dc=com
```
## User filter
Only users in the `metabase_users` group can log in
```
(&(objectClass=inetOrgPerson)(|(uid={login})(mail={login}))(memberOf=cn=metabase_users,ou=groups,dc=example,dc=com))
```
## Email attribute
```
mail
```
## First name attribute
```
givenname
```
## Last name attribute
```
cn
```
## Group Schema
**Synchronize Group Memberships**: Check this option to synchronize LDAP group memberships.
**New Mapping**: Create a new mapping between Metabase and LDAP groups:
- **Group Name**: `cn=metabase_users,ou=groups,dc=example,dc=com`
## Group search base
```
ou=groups,dc=example,dc=com
```
## Useful links
> [Metabase docker-compose.yaml](https://www.metabase.com/docs/latest/troubleshooting-guide/ldap)

View File

@@ -35,3 +35,38 @@ Creating MinIO policies is outside of the scope for this document, but it is wel
- Alias your MinIO instance: `mc alias set myMinIO http://<your-minio-address>:<your-minio-api-port> admin <your-admin-password>`
- Attach a policy to your LDAP group: `mc admin policy attach myMinIO consoleAdmin --group='cn=minio_admin,ou=groups,dc=example,dc=com'`
## Alternative configuration
The above options didn't work for me (thielj; 2024-6-10; latest lldap and minio docker images). In particular, having a User DN search base of `ou=people,dc=example,dc=com` conflicted with the condition `memberOf=cn=admins,ou=groups,dc=example,dc=com` due to the groups being outside the 'ou=people' search base. Using just `dc=example,dc=com` as search base was frowned upon by MinIO due to duplicate results.
The following environment variables made both MinIO and LLDAP happy:
```yaml
environment:
MINIO_ROOT_USER: "admin"
MINIO_ROOT_PASSWORD: "${ADMIN_PASSWORD:?error}"
MINIO_IDENTITY_LDAP_SERVER_ADDR: "ldap.${TOP_DOMAIN}:636"
#MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY: "off"
#MINIO_IDENTITY_LDAP_SERVER_INSECURE: "off"
#MINIO_IDENTITY_LDAP_SERVER_STARTTLS: "off"
# https://github.com/lldap/lldap/blob/main/example_configs/minio.md
MINIO_IDENTITY_LDAP_LOOKUP_BIND_DN: "${LDAP_AUTH_BIND_USER}"
MINIO_IDENTITY_LDAP_LOOKUP_BIND_PASSWORD: "${LDAP_AUTH_BIND_PASSWORD}"
MINIO_IDENTITY_LDAP_USER_DN_SEARCH_BASE_DN: "ou=people,${LDAP_BASE_DN}"
# allow all users to login; they need a policy attached before they can actually do anything
MINIO_IDENTITY_LDAP_USER_DN_SEARCH_FILTER: "(&(objectclass=posixAccount)(uid=%s))"
#MINIO_IDENTITY_LDAP_USER_DN_ATTRIBUTES: "uid,cn,mail"
MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN: "ou=groups,${LDAP_BASE_DN}"
MINIO_IDENTITY_LDAP_GROUP_SEARCH_FILTER: "(&(objectclass=groupOfUniqueNames)(member=%d))"
```
Another tip, there's no need to download or install the MinIO CLI. Assuming your running container is named `minio`, this does the trick:
```
$ docker exec minio mc alias set localhost http://localhost:9000 admin "${ADMIN_PASSWORD}"
$ docker exec minio mc ready localhost
$ docker exec minio mc admin policy attach localhost consoleAdmin --group="cn=admins,ou=groups,${LDAP_BASE_DN}"
```

149
example_configs/netbox.md Normal file
View File

@@ -0,0 +1,149 @@
# Configuration for Netbox
Netbox LDAP configuration is located [here](https://netboxlabs.com/docs/netbox/en/stable/installation/6-ldap/)
## Prerequisites
1. Install requirements
**Debian/Ubuntu:** `sudo apt install -y libldap2-dev libsasl2-dev libssl-dev`
**CentOS:** `sudo yum install -y openldap-devel python3-devel`
2. Install django-auth-ldap
`source /opt/netbox/venv/bin/activatepip3 install django-auth-ldap`
3. Add package to local requirements
`sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"`
4. Enable LDAP backend in configuration.py (*default: /opt/netbox/netbox/netbox/configuration.py*)
`REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'`
## LDAP Configuration
1. Create ldap_config.py file
`touch /opt/netbox/netbox/netbox/ldap_config.py`
2. Copy and modify the configuration below
```python
import ldap
from django_auth_ldap.config import LDAPSearch, NestedGroupOfNamesType
# Server URI
AUTH_LDAP_SERVER_URI = "ldaps://lldap.example.com:6360"
# Connection options, if necessary
AUTH_LDAP_CONNECTION_OPTIONS = {
ldap.OPT_REFERRALS: 0 # Disable referral chasing if not needed
}
# Bind DN and password for the service account
AUTH_LDAP_BIND_DN = "uid=admin,ou=people,dc=example,dc=com"
AUTH_LDAP_BIND_PASSWORD = "ChangeMe!"
# Ignore certificate errors (for self-signed certificates)
LDAP_IGNORE_CERT_ERRORS = False # Only use in development or testing!
# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
# Note that this is a NetBox-specific setting which sets:
# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
LDAP_CA_CERT_DIR = '/etc/ssl/certs'
# Include this setting if you want to validate the LDAP server certificates against your own CA.
# Note that this is a NetBox-specific setting which sets:
# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
# User search configuration
AUTH_LDAP_USER_SEARCH = LDAPSearch(
"ou=people,dc=example,dc=com",
ldap.SCOPE_SUBTREE,
"(uid=%(user)s)"
)
# User DN template
AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=people,dc=example,dc=com"
# Map LDAP attributes to Django user attributes
AUTH_LDAP_USER_ATTR_MAP = {
"username": "uid",
"email": "mail",
"first_name": "givenName",
"last_name": "sn",
}
# Group search configuration
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
"ou=groups,dc=example,dc=com",
ldap.SCOPE_SUBTREE,
"(objectClass=group)"
)
AUTH_LDAP_GROUP_TYPE = NestedGroupOfNamesType()
# Require users to be in a specific group to log in
AUTH_LDAP_REQUIRE_GROUP = "cn=netbox_users,ou=groups,dc=example,dc=com"
# Mirror LDAP group assignments
AUTH_LDAP_MIRROR_GROUPS = True
# Map LDAP groups to Django user flags
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
"is_superuser": "cn=netbox_admins,ou=groups,dc=example,dc=com"
}
# Find group permissions
AUTH_LDAP_FIND_GROUP_PERMS = True
# Cache group memberships to reduce LDAP traffic
AUTH_LDAP_CACHE_TIMEOUT = 3600
# Always update user information from LDAP on login
AUTH_LDAP_ALWAYS_UPDATE_USER = True
```
3. Restart netbox and netbox-rq
`sudo systemctl restart netbox netbox-rq`
## Troubleshoot LDAP
1. Make logging directory
`sudo mkdir -p /opt/netbox/local/logs/`
2. Make log file
`sudo touch /opt/netbox/local/logs/django-ldap-debug.log`
3. Set permissions
`sudo chown -R netbox:root /opt/netbox/local`
4. Add the following to */opt/netbox/netbox/netbox/configuration.py*
```py
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'netbox_auth_log': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'filename': '/opt/netbox/local/logs/django-ldap-debug.log',
'maxBytes': 1024 * 500,
'backupCount': 5,
},
},
'loggers': {
'django_auth_ldap': {
'handlers': ['netbox_auth_log'],
'level': 'DEBUG',
},
},
}
```

View File

@@ -74,6 +74,7 @@ occ ldap:set-config s01 ldapUserDisplayName displayname
occ ldap:set-config s01 ldapUserFilterMode 1
occ ldap:set-config s01 ldapUuidGroupAttribute auto
occ ldap:set-config s01 ldapUuidUserAttribute auto
occ ldap:set-config s01 ldapExpertUsernameAttr user_id
```
With a bit of of luck, you should be able to log in your nextcloud instance with LLDAP accounts in the `nextcloud_users` group.
@@ -114,9 +115,16 @@ Enter a valid username in lldap and check if your filter is working.
### Groups
You can use the menus for this part : select `groupOfUniqueNames` in the first menu and check every group you want members to be allowed to view their group member / share files with.
For example:
```
(&(|(objectclass=groupOfUniqueNames))(|(cn=family)(cn=friends)))
```
![groups configuration page](images/nextcloud_groups.png)
### Expert
Set `Internal Username` to `user_id`. This is needed to that the user ID used by Nextcloud corresponds to the `user_id` field and not the `UUID` field.
## Sharing restrictions
Go to Settings > Administration > Sharing and check following boxes :

View File

@@ -19,7 +19,7 @@ Click `Verify connection` if successful click `Next`
* Select a template: Generic ldap server
* User Relative DN: `ou=people`
* User subtree: Leave unchecked
* Object class: person
* Object class: `person`
* User Filter: Leave empty to allow all users to log in or `(memberOf=uid=nexus_users,ou=groups,dc=example,dc=com)` for a specific group
* Username Attribute: `uid`
* Real Name Attribute: `cn`
@@ -53,4 +53,4 @@ Click `Create Role`
* Role Name: e.g. nexus_admin (group in lldap)
* Add privileges/roles as needed e.g. under Roles add nx-admin to the "contained" list
Click `Save`
Click `Save`

99
example_configs/ocis.md Normal file
View File

@@ -0,0 +1,99 @@
# OCIS (OwnCloud Infinite Scale)
This is using version 5 which is currently still in RC.
IMPORTANT: There is a bug/quirk in how the OCIS container handles bind mounts.
If the bind mount locations (eg. `/srv/ocis/{app,cfg}`) don't exist when the container is started, OCIS creates them with `root` permissions. It then seems to drop permissions to UID 1000 and gives an error because it can't create files in the `{app,cfg}`.
So you must create the bind mount locations and manually chown them to uid/gid 1000, eg.
```
# cd /srv/ocis
# mkdir app cfg
# chown 1000:1000 app cfg
# docker compose up -d && docker compose logs -f
```
## .env
```
OCIS_URL="https://ocis.example.nz"
LDAP_BASE_DN="dc=example,dc=nz"
LDAP_BIND_PASSWORD=very-secret-yogurt
# LLDAP UUID to be given admin permissions
LLDAP_ADMIN_UUID=c1c2428a-xxxx-yyyy-zzzz-6cc946bf6809
```
## docker-compose.yml
```
version: "3.7"
networks:
caddy:
external: true
services:
ocis:
image: owncloud/ocis:5.0.0-rc.4
container_name: ocis
networks:
- caddy
entrypoint:
- /bin/sh
command: ["-c", "ocis init || true; ocis server"]
environment:
OCIS_URL: ${OCIS_URL}
OCIS_LOG_LEVEL: warn
OCIS_LOG_COLOR: "false"
PROXY_TLS: "false" # do not use SSL between Traefik and oCIS
OCIS_INSECURE: "false"
# Basic Auth is required for WebDAV clients that don't support OIDC
PROXY_ENABLE_BASIC_AUTH: "false"
#IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD}" # Not needed if admin user is in LDAP (?)
#OCIS_PASSWORD_POLICY_BANNED_PASSWORDS_LIST: "banned-password-list.txt"
# Assumes your LLDAP container is named `lldap`
OCIS_LDAP_URI: ldap://lldap:3890
OCIS_LDAP_INSECURE: "true"
OCIS_LDAP_BIND_DN: "uid=admin,ou=people,${LDAP_BASE_DN}"
OCIS_LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD}
OCIS_ADMIN_USER_ID: ${LLDAP_ADMIN_UUID}
OCIS_LDAP_USER_ENABLED_ATTRIBUTE: uid
GRAPH_LDAP_SERVER_WRITE_ENABLED: "false" # Does your LLDAP bind user have write access?
GRAPH_LDAP_REFINT_ENABLED: "false"
# Disable the built in LDAP server
OCIS_EXCLUDE_RUN_SERVICES: idm
# both text and binary cause errors in LLDAP, seems harmless though (?)
#IDP_LDAP_UUID_ATTRIBUTE_TYPE: 'text'
LDAP_LOGIN_ATTRIBUTES: "uid"
IDP_LDAP_LOGIN_ATTRIBUTE: "uid"
IDP_LDAP_UUID_ATTRIBUTE: "entryuuid"
OCIS_LDAP_USER_SCHEMA_ID: "entryuuid"
OCIS_LDAP_GROUP_SCHEMA_ID: "uid"
OCIS_LDAP_GROUP_SCHEMA_GROUPNAME: "uid"
OCIS_LDAP_GROUP_BASE_DN: "ou=groups,${LDAP_BASE_DN}"
OCIS_LDAP_GROUP_OBJECTCLASS: "groupOfUniqueNames"
# can filter which groups are imported, eg: `(&(objectclass=groupOfUniqueNames)(uid=ocis_*))`
OCIS_LDAP_GROUP_FILTER: "(objectclass=groupOfUniqueNames)"
OCIS_LDAP_USER_BASE_DN: "ou=people,${LDAP_BASE_DN}"
OCIS_LDAP_USER_OBJECTCLASS: "inetOrgPerson"
# Allows all users
#OCIS_LDAP_USER_FILTER: "(objectclass=inetOrgPerson)"
# Allows users who are in the LLDAP group `ocis_users`
OCIS_LDAP_USER_FILTER: "(&(objectclass=person)(memberOf=cn=ocis_users,ou=groups,${LDAP_BASE_DN}))"
# NOT WORKING: Used instead of restricting users with OCIS_LDAP_USER_FILTER
#OCIS_LDAP_DISABLE_USER_MECHANISM: "group"
#OCIS_LDAP_DISABLED_USERS_GROUP_DN: "uid=ocis_disabled,ou=groups,${LDAP_BASE_DN}"
volumes:
# - ./config/ocis/banned-password-list.txt:/etc/ocis/banned-password-list.txt
# IMPORTANT: see note at top about creating/cowning bind mounts
- ./cfg:/etc/ocis
- ./app:/var/lib/ocis
restart: always
```

23
example_configs/onedev.md Normal file
View File

@@ -0,0 +1,23 @@
# Configuration for OneDev
In Onedev, go to `Administration > Authentication Sources` and click `External Authentication`
Select `Generic LDAP`
* LDAP URL: ldap://lldap_ip_or_hostname:3890 or ldaps://lldap_ip_or_hostname:6360
* Authentication Required: On
* Manager DN: `uid=admin,ou=people,dc=example,dc=com`
* Manager Password: Your bind user's password
* User Search Base: `ou=people,dc=example,dc=com`
* User Full Name Attribute: `displayName`
* Email Attribute: mail
* User SSH Key Attribute: (Leave Blank)
* Group Retrieval: "Search Groups Using Filter"
* Group Search Base: `ou=groups,dc=example,dc=com`
* Group Search Filter" `(&(uniqueMember={0})(objectclass=groupOfUniqueNames))`
* Group Name Attribute: cn
* Create User As Guest: Off
* Default Group: "No Default Group"
* Timeout: 300
Replace every instance of `dc=example,dc=com` with your configured domain.
After applying the above settings, users should be able to log in with their user name.

View File

@@ -0,0 +1,90 @@
# Configure lldap
You MUST use LDAPS. You MUST NOT use plain ldap. Even over a private network
this costs you nearly nothing, and passwords will be sent in PLAIN TEXT without
it.
```toml
[ldaps_options]
enabled=true
port=6360
cert_file="cert.pem"
key_file="key.pem"
```
You can generate an SSL certificate for it with the following command. The
`subjectAltName` is REQUIRED. Make sure all domains are listed there, even your
`CN`.
```sh
openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 36500 -nodes -subj "/CN=lldap.example.net" -addext "subjectAltName = DNS:lldap.example.net"
```
# Install the client packages.
This guide used `libnss-ldapd` (which is different from `libnss-ldap`).
PURGE the following ubuntu packages: `libnss-ldap`, `libpam-ldap`
Install the following ubuntu packages: `libnss-ldapd`, `nslcd`, `nscd`, `libpam-ldapd`
# Configure the client's `nslcd` settings.
Edit `/etc/nslcd.conf`. Use the [provided template](./nslcd.conf).
You will need to set `tls_cacertfile` to a copy of the public portion of your
LDAPS certificate, which must be available on the client. This is used to
verify the LDAPS server identity.
You will need to add the `binddn` and `bindpw` settings.
The provided implementation uses custom attributes to mark users and groups
that should be included in the system (for instance, you don't want LDAP
accounts of other services to have a matching unix user).
For users, you need to add an (integer) `unix-uid` attribute to the schema, and
manually set the value for the users you want to enable to login with PAM.
For groups, you need an (integer) `unix-gid` attribute, similarly set manually
to some value.
If you want to change this representation, update the `filter passwd` and
`filter group` accordingly.
You should check whether you need to edit the `pam_authz_search` setting. This
is used after authentication, at the PAM `account` stage, to determine whether
the user should be allowed to log in. If someone is an LDAP user, even if they
use an SSH key to log in, they must still pass this check. The provided example
will check for membership of a group named `YOUR_LOGIN_GROUP_FOR_THIS_MACHINE`.
You should review the `map` settings. These contain custom attributes that you
will need to add to lldap and set on your users.
# Configure the client OS.
Ensure the `nslcd` and `nscd` services are installed and running. `nslcd`
provides LDAP NSS service. `nscd` provides caching for NSS databased. You want
the caching.
```
systemctl enable --now nslcd nscd
```
Configure PAM to create the home directory for LDAP users automatically at
first login.
```
pam-auth-update --enable mkhomedir
```
Edit /etc/nsswitch.conf and add "ldap" to the END of the "passwd" and "group"
lines.
You're done!
## Clearing nscd caches.
If you want to manually clear nscd's caches, run `nscd -i passwd; nscd -i group`.
[scripting]: https://github.com/lldap/lldap/blob/main/docs/scripting.md

View File

@@ -0,0 +1,59 @@
# /etc/nslcd.conf
# nslcd configuration file. See nslcd.conf(5)
# for details.
# The user and group nslcd should run as.
uid nslcd
gid nslcd
# The location at which the LDAP server(s) should be reachable.
uri ldaps://lldap.example.net:6360/
# The search base that will be used for all queries.
base dc=example,dc=net
# The LDAP protocol version to use.
#ldap_version 3
# The DN to bind with for normal lookups.
binddn cn=...,ou=people,dc=example,dc=com
bindpw ...
# The DN used for password modifications by root.
#rootpwmoddn cn=admin,dc=example,dc=com
# SSL options
#ssl off
tls_reqcert demand
tls_cacertfile /etc/cert-lldap.example.com.pem
# The search scope.
#scope sub
reconnect_invalidate passwd group
nss_initgroups_ignoreusers ALLLOCAL
# Do you have users/groups that aren't for linux? These filters determine which user/group objects are used.
filter passwd (&(objectClass=posixAccount)(unix-uid=*))
filter group (&(objectClass=groupOfUniqueNames)(unix-gid=*))
# This check is done AFTER authentication, in the pam "account" stage.
# Regardless of if they used a LDAP password, or an SSH key, if they're an LDAP user, they have to pass this check.
pam_authz_search (&(objectClass=posixAccount)(unix-uid=*)(unix-username=$username)(memberOf=cn=YOUR_LOGIN_GROUP_FOR_THIS_MACHINE,ou=groups,dc=example,dc=com))
map passwd uid unix-username
map passwd uidNumber unix-uid
map passwd gidNumber unix-gid
map passwd gecos unix-username
map passwd homeDirectory "/home/${unix-username}"
map passwd loginShell unix-shell
map group gidNumber unix-gid
map group memberUid member
nss_min_uid 1000
pam_password_prohibit_message "Please use the forgot password link on https://lldap.example.com/ to change your password."

View File

@@ -1,5 +1,8 @@
# Configuration for pfSense
> [!NOTE]
> Replace `dc=example,dc=com` with the same LDAP Base DN that you set via the *LLDAP_LDAP_BASE_DN* environment variable or in `lldap_config.toml`.
## Create a LDAP Server
- Login to pfSense
@@ -16,7 +19,9 @@
- Protocol version: `3`
- Server Timeout: `25`
(Make sure the host running LLDAP is accessible to pfSense and that you mapped the LLDAP port to the LLDAP host)
> [!NOTE]
> Make sure the host running LLDAP is accessible to pfSense and that you mapped the LLDAP port to the LLDAP host
### Search Scope
```
Entire Subtree
@@ -27,18 +32,25 @@ Entire Subtree
dc=example,dc=com
```
This is the same LDAP Base DN that you set via the *LLDAP_LDAP_BASE_DN* environment variable or in `lldap_config.toml`.
### Authentication containers
```
ou=people
```
Note: The `Select a container` box may not work for selecting containers. You can just enter the `Authentication containers` directly into the text field.
> [!Note]
> The `Select a container` seach fuction will not work for selecting containers. You enter the `Authentication containers` directly into the text field.
> This is due to Pfsense running the following filter `"(|(ou=*)(cn=Users))"`, and `Organizational Units` is currently not supported.Could not connect to the LDAP server. Please check the LDAP configuration.
> [!WARNING]
> if search button is pressed a warning will show on the bottom of the page: `Could not connect to the LDAP server. Please check the LDAP configuration.`
### Extended Query
Enable extended query: `Checked`
Enable extended query:
- [X] `Checked`
### Query:
@@ -49,7 +61,8 @@ Enable extended query: `Checked`
This example gives you two groups in LLDAP, one for pfSense admin access (`pfsense_admin`) and one for guest access (`pfsense_guest`). You **must** create these exact same groups in both LLDAP and pfSense, then give them the correct permissions in pfSense.
### Bind Anonymous
`Unchecked`
- [ ] `Unchecked`
### Bind credentials
@@ -80,13 +93,18 @@ cn
memberof
```
### RFC 2307 Groups
`Unchecked`
- [ ] `Unchecked`
### Group Object Class
`groupOfUniqueNames`
```
groupOfUniqueNames
```
### Shell Authentication Group DN
`cn=pfsense_admin,ou=groups,dc=example,dc=com`
```
cn=pfsense_admin,ou=groups,dc=example,dc=com
```
(This is only if you want to give a group shell access through LDAP. Leave blank and only the pfSense admin user will have shell access.
@@ -94,9 +112,9 @@ memberof
Enable the following options on the pfSense configuration page for your LLDAP server (the same page where you entered the prior configuration):
- UTF8 Encodes: `Checked`
- Username Alterations: `Unchecked`
- Allow unauthenticated bind: `Unchecked`
- [X] UTF8 Encodes: `Checked`
- [ ] Username Alterations: `Unchecked`
- [ ] Allow unauthenticated bind: `Unchecked`
### Create pfSense Groups
@@ -112,6 +130,9 @@ Go to `System > User Manager > Settings` page. Add your LLDAP server configurati
pfSense includes a built-in feature for testing user authentication at `Diagnostics > Authentication`. Select your LLDAP server configuration in the `Authentication Server` to test logins for your LLDAP users. The groups (only the ones you added to pfSense) should show up when tested.
> [!WARNING]
> When running `Save and test`, the `Attempting to fetch Organizational Units from` will fail. This is due to Pfsense running the following filter `"(|(ou=*)(cn=Users))"`, and `Organizational Units` is currently not supported.
## More Information
Please read the [pfSense docs](https://docs.netgate.com/pfsense/en/latest/usermanager/ldap.html) for more information on LDAP configuration and managing access to pfSense.

View File

@@ -0,0 +1,15 @@
# Configuration for Prosody XMPP server
Prosody is setup with virtual hosts, at least one. If you want to have users access only specific virtual hosts, create a group per vHost (I called it `xmpp-example.com`). If not, remove the memberOf part in the filter below. I would also create a read only user (mine is called `query`) with the group `lldap_strict_readonly` to find the users that will be used to bind.
In `prosody.cfg.lua` you need to set `authentication` to `ldap` and the following settings:
```authentication = "ldap"
ldap_base = "dc=example,dc=com"
ldap_server = "lldap_ip:3890"
ldap_rootdn = "uid=query,ou=people,dc=example,dc=com"
ldap_password = "query-password"
ldap_filter = "(&(uid=$user)(memberOf=cn=xmpp-$host,ou=groups,dc=example,dc=com)(objectclass=person))"
```
Restart Prosody and you should be good to go.

View File

@@ -0,0 +1,29 @@
# Configuration of Radicale authentication with LLDAP
## Native configuration (requires Radicale >=3.3.0)
```ini
[auth]
type = ldap
ldap_uri = ldap://lldap:3890
ldap_base = dc=example,dc=com
ldap_reader_dn = uid=admin,ou=people,dc=example,dc=com
ldap_secret = CHANGEME
ldap_filter = (&(objectClass=person)(uid={0}))
lc_username = True
```
## Plugin configuration (requires [radicale-auth-ldap](https://github.com/shroomify-it/radicale-auth-ldap-plugin) plugin and Radicale >=3.0)
```ini
[auth]
type = radicale_auth_ldap
ldap_url = ldap://lldap:3890
ldap_base = dc=example,dc=com
ldap_attribute = uid
ldap_filter = (objectClass=person)
ldap_binddn = uid=admin,ou=people,dc=example,dc=com
ldap_password = CHANGEME
ldap_scope = LEVEL
ldap_support_extended = no
```

View File

@@ -0,0 +1,57 @@
# Configuring LDAP in SonarQube
[SonarQube](https://github.com/SonarSource/sonarqube)
Continuous Inspection
---
SonarQube can configure ldap through environment variables when deploying using docker-compose
## docker-compose.yaml
```yaml
version: "3"
services:
sonarqube:
image: sonarqube:community
depends_on:
- db
environment:
SONAR_JDBC_URL: jdbc:postgresql://db:5432/sonar
SONAR_JDBC_USERNAME: sonar
SONAR_JDBC_PASSWORD: sonar
LDAP_URL: ldap://example.com:3890
LDAP_BINDDN: cn=admin,ou=people,dc=example,dc=com
LDAP_BINDPASSWORD: passwd
LDAP_AUTHENTICATION: simple
LDAP_USER_BASEDN: ou=people,dc=example,dc=com
LDAP_USER_REQUEST: (&(objectClass=inetOrgPerson)(uid={login})(memberof=cn=sonarqube_users,ou=groups,dc=example,dc=com))
LDAP_USER_REALNAMEATTRIBUTE: cn
LDAP_USER_EMAILATTRIBUTE: mail
volumes:
- sonarqube_data:/opt/sonarqube/data
- sonarqube_extensions:/opt/sonarqube/extensions
- sonarqube_logs:/opt/sonarqube/logs
ports:
- "9000:9000"
db:
image: postgres:12
environment:
POSTGRES_USER: sonar
POSTGRES_PASSWORD: sonar
volumes:
- postgresql:/var/lib/postgresql
- postgresql_data:/var/lib/postgresql/data
volumes:
sonarqube_data:
sonarqube_extensions:
sonarqube_logs:
postgresql:
postgresql_data:
```
> [SonarQube docker-compose.yaml example](https://docs.sonarsource.com/sonarqube/latest/setup-and-upgrade/install-the-server/installing-sonarqube-from-docker/)

View File

@@ -9,12 +9,11 @@ Replace `dc=example,dc=com` with your LLDAP configured domain.
version: '3'
services:
ldap_sync:
image: vividboarder/vaultwarden_ldap:0.6-alpine
image: vividboarder/vaultwarden_ldap:2.0.2
volumes:
- ./config.toml:/config.toml:ro
environment:
CONFIG_PATH: /config.toml
RUST_BACKTRACE: 1
restart: always
```
Configuration to use LDAP in `config.toml`
@@ -23,6 +22,7 @@ vaultwarden_url = "http://your_bitwarden_url:port"
vaultwarden_admin_token = "insert_admin_token_vaultwarden"
ldap_host = "insert_ldap_host"
ldap_port = 3890
ldap_ssl = false # true if using LDAPS
ldap_bind_dn = "uid=admin,ou=people,dc=example,dc=com"
ldap_bind_password = "insert_admin_pw_ldap"
ldap_search_base_dn = "dc=example,dc=com"

View File

@@ -1,6 +1,6 @@
#! /bin/sh
function print_random () {
print_random () {
LC_ALL=C tr -dc 'A-Za-z0-9!#%&()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32
}

View File

@@ -82,7 +82,8 @@
## Break glass in case of emergency: if you lost the admin password, you
## can set this to true to force a reset of the admin password to the value
## of ldap_user_pass above.
# force_reset_admin_password = false
## Alternatively, you can set it to "always" to reset every time the server starts.
# force_ldap_user_pass_reset = false
## Database URL.
## This encodes the type of database (SQlite, MySQL, or PostgreSQL)

View File

@@ -182,14 +182,14 @@ impl TryFrom<ResultEntry> for User {
.attrs
.get("jpegPhoto")
.map(|v| v.iter().map(|s| s.as_bytes().to_vec()).collect::<Vec<_>>())
.or_else(|| entry.bin_attrs.get("jpegPhoto").map(Clone::clone))
.or_else(|| entry.bin_attrs.get("jpegPhoto").cloned())
.and_then(|v| v.into_iter().next().filter(|s| !s.is_empty()));
let password =
get_optional_attribute("userPassword").or_else(|| get_optional_attribute("password"));
Ok(User::new(
crate::lldap::CreateUserInput {
id,
email,
email: Some(email),
display_name,
first_name,
last_name,

17
schema.graphql generated
View File

@@ -1,6 +1,7 @@
type AttributeValue {
name: String!
value: [String!]!
schema: AttributeSchema!
}
type Mutation {
@@ -17,6 +18,10 @@ type Mutation {
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
deleteUserAttribute(name: String!): Success!
deleteGroupAttribute(name: String!): Success!
addUserObjectClass(name: String!): Success!
addGroupObjectClass(name: String!): Success!
deleteUserObjectClass(name: String!): Success!
deleteGroupObjectClass(name: String!): Success!
}
type Group {
@@ -58,7 +63,7 @@ type Query {
"The details required to create a user."
input CreateUserInput {
id: String!
email: String!
email: String
displayName: String
firstName: String
lastName: String
@@ -73,6 +78,7 @@ type AttributeSchema {
isVisible: Boolean!
isEditable: Boolean!
isHardcoded: Boolean!
isReadonly: Boolean!
}
"The fields that can be updated for a user."
@@ -152,10 +158,6 @@ type User {
groups: [Group!]!
}
type AttributeList {
attributes: [AttributeSchema!]!
}
enum AttributeType {
STRING
INTEGER
@@ -163,6 +165,11 @@ enum AttributeType {
DATE_TIME
}
type AttributeList {
attributes: [AttributeSchema!]!
extraLdapObjectClasses: [String!]!
}
type Success {
ok: Boolean!
}

View File

@@ -3,14 +3,24 @@
set -e
set -o pipefail
LLDAP_URL="${LLDAP_URL}"
LLDAP_ADMIN_USERNAME="${LLDAP_ADMIN_USERNAME}"
LLDAP_ADMIN_PASSWORD="${LLDAP_ADMIN_PASSWORD}"
USER_CONFIGS_DIR="${USER_CONFIGS_DIR:-/user-configs}"
GROUP_CONFIGS_DIR="${GROUP_CONFIGS_DIR:-/group-configs}"
LLDAP_URL="${LLDAP_URL:-http://localhost:17170}"
LLDAP_ADMIN_USERNAME="${LLDAP_ADMIN_USERNAME:-admin}"
LLDAP_ADMIN_PASSWORD="${LLDAP_ADMIN_PASSWORD:-password}"
USER_SCHEMAS_DIR="${USER_SCHEMAS_DIR:-/bootstrap/user-schemas}"
GROUP_SCHEMAS_DIR="${GROUP_SCHEMAS_DIR:-/bootstrap/group-schemas}"
USER_CONFIGS_DIR="${USER_CONFIGS_DIR:-/bootstrap/user-configs}"
GROUP_CONFIGS_DIR="${GROUP_CONFIGS_DIR:-/bootstrap/group-configs}"
LLDAP_SET_PASSWORD_PATH="${LLDAP_SET_PASSWORD_PATH:-/app/lldap_set_password}"
DO_CLEANUP="${DO_CLEANUP:-false}"
# Fallback to support legacy defaults
if [[ ! -d $USER_CONFIGS_DIR ]] && [[ -d "/user-configs" ]]; then
USER_CONFIGS_DIR="/user-configs"
fi
if [[ ! -d $GROUP_CONFIGS_DIR ]] && [[ -d "/group-configs" ]]; then
GROUP_CONFIGS_DIR="/group-configs"
fi
check_install_dependencies() {
local commands=('curl' 'jq' 'jo')
local commands_not_found='false'
@@ -252,7 +262,7 @@ get_users_list() {
}
user_exists() {
if [[ "$(get_users_list | jq --raw-output --arg id "$1" '.data.users | any(.[]; contains({"id": $id}))')" == 'true' ]]; then
if [[ "$(get_users_list | jq --raw-output --arg id "$1" '.data.users | any(.[]; .id == $id)')" == 'true' ]]; then
return 0
else
return 1
@@ -280,6 +290,80 @@ delete_user() {
fi
}
get_group_property_list() {
local query='{"query":"query GetGroupAttributesSchema { schema { groupSchema { attributes { name }}}}","operationName":"GetGroupAttributesSchema"}'
make_query <(printf '%s' "$query") <(printf '{}')
}
group_property_exists() {
if [[ "$(get_group_property_list | jq --raw-output --arg name "$1" '.data.schema.groupSchema.attributes | any(.[]; select(.name == $name))')" == 'true' ]]; then
return 0
else
return 1
fi
}
create_group_schema_property() {
local name="$1"
local attributeType="$2"
local isEditable="$3"
local isList="$4"
local isVisible="$5"
if group_property_exists "$name"; then
printf 'Group property "%s" already exists\n' "$name"
return
fi
# shellcheck disable=SC2016
local query='{"query":"mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {ok}}","operationName":"CreateGroupAttribute"}'
local response='' error=''
response="$(make_query <(printf '%s' "$query") <(jo -- name="$name" attributeType="$attributeType" isEditable="$isEditable" isList="$isList" isVisible="$isVisible"))"
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
if [[ -n "$error" ]]; then
printf '%s\n' "$error"
else
printf 'Group attribute "%s" successfully created\n' "$name"
fi
}
get_user_property_list() {
local query='{"query":"query GetUserAttributesSchema { schema { userSchema { attributes { name }}}}","operationName":"GetUserAttributesSchema"}'
make_query <(printf '%s' "$query") <(printf '{}')
}
user_property_exists() {
if [[ "$(get_user_property_list | jq --raw-output --arg name "$1" '.data.schema.userSchema.attributes | any(.[]; select(.name == $name))')" == 'true' ]]; then
return 0
else
return 1
fi
}
create_user_schema_property() {
local name="$1"
local attributeType="$2"
local isEditable="$3"
local isList="$4"
local isVisible="$5"
if user_property_exists "$name"; then
printf 'User property "%s" already exists\n' "$name"
return
fi
# shellcheck disable=SC2016
local query='{"query":"mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {ok}}","operationName":"CreateUserAttribute"}'
local response='' error=''
response="$(make_query <(printf '%s' "$query") <(jo -- name="$name" attributeType="$attributeType" isEditable="$isEditable" isList="$isList" isVisible="$isVisible"))"
error="$(printf '%s' "$response" | jq --raw-output '.errors | if . != null then .[].message else empty end')"
if [[ -n "$error" ]]; then
printf '%s\n' "$error"
else
printf 'User attribute "%s" successfully created\n' "$name"
fi
}
__common_user_mutation_query() {
local \
query="$1" \
@@ -387,8 +471,18 @@ main() {
local user_config_files=("${USER_CONFIGS_DIR}"/*.json)
local group_config_files=("${GROUP_CONFIGS_DIR}"/*.json)
local user_schema_files=()
local group_schema_files=()
if ! check_configs_validity "${group_config_files[@]}" "${user_config_files[@]}"; then
local file=''
[[ -d "$USER_SCHEMAS_DIR" ]] && for file in "${USER_SCHEMAS_DIR}"/*.json; do
user_schema_files+=("$file")
done
[[ -d "$GROUP_SCHEMAS_DIR" ]] && for file in "${GROUP_SCHEMAS_DIR}"/*.json; do
group_schema_files+=("$file")
done
if ! check_configs_validity "${group_config_files[@]}" "${user_config_files[@]}" "${group_schema_files[@]}" "${user_schema_files[@]}"; then
exit 1
fi
@@ -399,6 +493,28 @@ main() {
auth "$LLDAP_URL" "$LLDAP_ADMIN_USERNAME" "$LLDAP_ADMIN_PASSWORD"
printf -- '\n--- group schemas ---\n'
local group_schema_config_row=''
[[ ${#group_schema_files[@]} -gt 0 ]] && while read -r group_schema_config_row; do
local field='' name='' attributeType='' isEditable='' isList='' isVisible=''
for field in 'name' 'attributeType' 'isEditable' 'isList' 'isVisible'; do
declare "$field"="$(printf '%s' "$group_schema_config_row" | jq --raw-output --arg field "$field" '.[$field]')"
done
create_group_schema_property "$name" "$attributeType" "$isEditable" "$isList" "$isVisible"
done < <(jq --compact-output '.[]' -- "${group_schema_files[@]}")
printf -- '--- group schemas ---\n'
printf -- '\n--- user schemas ---\n'
local user_schema_config_row=''
[[ ${#user_schema_files[@]} -gt 0 ]] && while read -r user_schema_config_row; do
local field='' name='' attributeType='' isEditable='' isList='' isVisible=''
for field in 'name' 'attributeType' 'isEditable' 'isList' 'isVisible'; do
declare "$field"="$(printf '%s' "$user_schema_config_row" | jq --raw-output --arg field "$field" '.[$field]')"
done
create_user_schema_property "$name" "$attributeType" "$isEditable" "$isList" "$isVisible"
done < <(jq --compact-output '.[]' -- "${user_schema_files[@]}")
printf -- '--- user schemas ---\n'
local redundant_groups=''
redundant_groups="$(get_group_list | jq '[ .data.groups[].displayName ]' | jq --compact-output '. - ["lldap_admin","lldap_password_manager","lldap_strict_readonly"]')"

View File

@@ -8,7 +8,7 @@ keywords = ["cli", "ldap", "graphql", "server", "authentication"]
license = "GPL-3.0-only"
name = "lldap"
repository = "https://github.com/lldap/lldap"
version = "0.5.1-alpha"
version = "0.6.1"
[dependencies]
actix = "0.13"
@@ -25,7 +25,6 @@ base64 = "0.21"
bincode = "1.3"
cron = "*"
derive_builder = "0.12"
derive_more = "0.99"
figment_file_provider_adapter = "0.1"
futures = "*"
futures-util = "*"
@@ -35,7 +34,7 @@ itertools = "0.10"
juniper = "0.15"
jwt = "0.16"
lber = "0.4.1"
ldap3_proto = "^0.4.3"
ldap3_proto = "^0.5.1"
log = "*"
orion = "0.17"
rand_chacha = "0.3"
@@ -64,6 +63,11 @@ version = "*"
features = ["std", "color", "suggestions", "derive", "env"]
version = "4"
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.figment]
features = ["env", "toml"]
version = "*"
@@ -82,7 +86,7 @@ path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.opaque-ke]
version = "0.6"
version = "0.7"
[dependencies.rand]
features = ["small_rng", "getrandom"]
@@ -118,9 +122,15 @@ default-features = false
version = "0.24"
[dependencies.sea-orm]
version= "0.12"
version = "0.12"
default-features = false
features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"]
features = [
"macros",
"with-chrono",
"with-uuid",
"sqlx-all",
"runtime-actix-rustls",
]
[dependencies.reqwest]
version = "0.11"

Some files were not shown because too many files have changed in this diff Show More