Compare commits

...

536 Commits

Author SHA1 Message Date
Valentin Tolmer 5e83ed8eb0 release: v0.6.2 2025-08-18 00:06:44 +02:00
Kirill Zhuravlev c69957690e docs: avoid bad-sounding words in secrets example 2025-08-17 23:10:45 +02:00
Linus Astel 7ef2af8beb devcontainer: Bump Rust version 2025-08-14 22:38:45 +02:00
Toby 5c9897b156 ldap: Add missing subschema entries 2025-08-14 16:04:28 +02:00
ibizaman 0b720aa082 bootstrap: fine grained cleanup 2025-08-13 09:36:21 +02:00
dependabot[bot] 3e7277e77d build(deps): bump actions/checkout from 4.2.2 to 5.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0.
- [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.2...v5.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 08:02:59 +02:00
ibizaman 5241626a3a bootstrap: make password_file a standard custom attribute
Otherwise the bootstrap script tries to create the password_file
as a custom attribute which fails since it's not in the schema.
And anyway, it shouldn't be in the schema.
2025-08-06 22:13:22 +02:00
Valentin Tolmer 363ef106e2 app: Fix attribute type parsing 2025-07-30 01:02:47 +02:00
ibizaman 3c7e4c3dec bootstrap: do not leak password in process list 2025-07-22 08:51:35 +02:00
Valentin Tolmer fa196a9fd9 docker: try several GPG server
Sometimes the docker build fails because the gpg server is intermittently unavailable
2025-07-22 01:10:25 +02:00
ibizaman f02b365478 bootstrap: do not fail if no user or group defined 2025-07-21 23:35:49 +02:00
Valentin Tolmer 0b0e6ae2cd github: Fix warnings about Dockerfile syntax 2025-07-21 23:23:37 +02:00
Valentin Tolmer da525fc99b app: simplify attribute_type handling, display creation time in user details
In the user table it's still only the date, but that makes sense for an overview
2025-07-21 23:15:46 +02:00
ibizaman 78337bce72 bootstrap: allow to give password from a file 2025-07-16 23:51:21 +02:00
selfhoster1312 87e9311a44 meta: Fix cargo clippy failures (format strings) 2025-07-16 23:23:08 +02:00
Hendrik Sievers 53e62ecf5a docs: move authelia configuration to markdown file (#1205) 2025-07-13 22:29:09 +02:00
core 10d33a7537 readme: fix broken Iink 2025-07-11 00:52:03 +02:00
copilot-swe-agent[bot] ada438398e set-password: load system certificates
Fixes #1206
2025-07-08 22:46:13 +02:00
selfhoster1312 8c65d8958a docs: Add FAQ about sustainability and professional support contracts (#1200) 2025-07-06 23:55:08 +02:00
Toby f8cd7ad023 server, ldap: add support for Subschema requests (#1071)
Add a subschema entry to the rootDSE, which shows all attributes and
objectclasses present on the LLDAP server, which is needed for some
applications that need to index the LDAP server. The current
implementation's goal is to have a bare minimum working subschema which
follows the LDAP RFC. It also updates the GraphQL interface to follow
the changes that have been made in actually separating out
objectclasses, instead of having them as an attribute.

Co-authored-by: nitnelave <valentin@tolmer.fr>
2025-07-06 23:42:53 +02:00
selfhoster1312 823adcefd0 docs: Document (lack of) vhosts support (#1201) 2025-07-06 23:32:28 +02:00
selfhoster1312 5b120a5958 docs: Split README into smaller files (#1198) 2025-07-06 23:12:48 +02:00
Alexandre Foley c658666b3f example_configs: Quadlet documentation and improvements 2025-06-30 19:47:32 +02:00
PHIDIAS 7a5a88384d example_configs: add Mailcow (#1188) 2025-06-06 19:13:26 +02:00
Jona Joachim 4eb4fae49c example_configs: wiki_js: Add missing closing curly brackets to filter 2025-06-02 12:55:48 +02:00
Bryan Alves 58b028ad5f example_configs: fix Authelia OU for helm installations
Authelia when installed via helm by default looks for users
in the `Users` OU.  It supports changing this configuration via the
`additional_users_dn` property.  Set this to match what lldap expects.
2025-06-01 09:11:21 +02:00
Josh Thorpe 612bce48ad example_configs: fix overly-permissive next loud config
Any LLDAP user, not just members of nextcloud-users, could log in and get an instance. However, they weren't synchronized to the nextcloud admin view and thus were nearly invisible.
2025-05-30 10:23:07 +02:00
aokblast 1b5f6bfa66 example_configs: correct the manual for pkg support in FreeBSD 2025-05-19 20:28:23 +02:00
Nick 5913d81a44 chore: upgrade top level docker alpine version 2025-05-19 08:13:01 +02:00
sdelnevo cb9fd38271 example_configs: Add UnifiOS Directory 2025-05-17 23:34:53 +02:00
ChibyX 97bcfd1a99 example_configs: Add Peertube 2025-05-16 16:11:38 +02:00
Christian Medel 7330496a77 example_configs: Add Snipe-IT 2025-05-14 23:50:17 +09:00
MikaelUrankar 0baee7a120 Readme: Fix typo
s#LLDPA#LLDAP#g
2025-05-09 22:03:37 +09:00
broemp 0a5b2d4c46 example_configs: Change Grocy example config Port 2025-05-04 09:04:45 +09:00
cogdavid 9978111bec example_configs: fix dovecot example
ACCOUNT_PROVISIONER=LDAP is supported and indeed necessary for ldap integration - however quotas must be explicitly disabled with ENABLE_QUOTAS=0
2025-04-27 06:03:16 +09:00
Valentin Tolmer 8e25e9b2a4 app: Add a create user/group button at the top 2025-04-25 15:51:16 +09:00
Valentin Tolmer 4d6402c838 app: Fix email validation for groups
Fixes #1092
2025-04-25 15:36:43 +09:00
Valentin Tolmer b4f636ded9 server: Introduce True/False for filters
This should help clean up the filter debug representations
2025-04-25 15:25:26 +09:00
cogdavid 4018a6933c exmaple_configs: fix account provisioner in dovecot config
Updated ACCOUNT_PROVISIONER variable in environment directive. Change from ACCOUNT_PROVISIONER=LDAP to ACCOUNT_PROVISIONER=FILE.
2025-04-23 22:33:58 +09:00
Antonio Vargas bd29c7282d bootstrap: Include custom attributes when bootstrapping user and group configs (#1155)
* Include custom attributes when bootstrapping user and group configs

* Fix logic to detect user/group config without custom attributes

* Increase readability of query definition using a heredoc

* Remove duplicate query variables and improve readability

* Revert "Increase readability of query definition using a heredoc"

This reverts commit 7a73dacc21.
2025-04-21 16:07:11 +09:00
v-mod 1f89059c84 example_config: Add SSSD
* example_config: moving nslcd old guide to NSLCD.md

* example_config: creating README for SSSD

* example_config: creating sssd.conf

* example_config: removing explicit links and adding a reference to the old NSLCD guide

* example_config: fixing images in pam README

* example_config: add how to enable automatic home directory creation

* example_config: fixing typo in command to edit ssh config

* example_config: using commments instead of line numbers for the example sssd.conf file

---------

Co-authored-by: nitnelave <valentin@tolmer.fr>
2025-04-09 10:15:03 +09:00
Valentin Tolmer 74dbba0bdc server: dependency cleanup 2025-04-09 09:30:39 +09:00
Valentin Tolmer 3556e41612 server: flatten remaining files 2025-04-09 09:30:39 +09:00
Valentin Tolmer d38a2cd08b server: extract graphql crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer db77a0f023 server: rename sql_backend_server -> sql_tcp_backend_server 2025-04-09 09:30:39 +09:00
Valentin Tolmer 3d61c209d2 server: small dependency cleanup 2025-04-09 09:30:39 +09:00
Valentin Tolmer 55de3ac329 server: extract the sql backend handler to a separate crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer ee21d83056 server: extract ldap operations to a crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer a49ddeaa02 server: extract opaque_handler to a separate crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer dbba4c4e26 server: extract access_control to a separate crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer 0eef966c3e server: Move PublicSchema to the domain crate 2025-04-09 09:30:39 +09:00
Valentin Tolmer cdf43f2a69 server: cleanup extra mut in ldap handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 7450ff1028 server: Add support for deleting users and groups via LDAP 2025-04-09 09:30:39 +09:00
Valentin Tolmer c3ae149ae3 server: add tests for ldap modify 2025-04-09 09:30:39 +09:00
Valentin Tolmer 0a05a091d8 server: split off modify requests from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 6a2a5fe7f5 server: split off create_* from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 52f22c00c3 server: split off compare from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 37a85b4c2e server: split off password handling from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer 63f8b51c88 server: split off do_bind from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer c4aca0dad7 server: split off ldap/search from ldap_handler 2025-04-09 09:30:39 +09:00
Valentin Tolmer b8f114bd43 ldap: add support for creating groups 2025-04-08 19:15:47 -05:00
Valentin Tolmer 31364da6d4 chore: add prepare-release to Makefile 2025-04-04 10:43:48 -05:00
Juntong Zhu 853c561314 example_config: fix kimai.yaml 2025-04-01 06:47:53 -05:00
Valentin Tolmer 0aa31a282a app: Remove max-width in main component 2025-03-31 22:07:43 -05:00
ivan-sirosh 41e38234ed example_config: Add penpot 2025-03-31 11:42:26 -05:00
Valentin Tolmer ba9bcb3894 chore: Migrate all the crates to edition 2024 2025-03-30 21:32:46 -05:00
Valentin Tolmer e18f2af54f cargo: Migrate metadata to workspace 2025-03-30 20:56:16 -05:00
Valentin Tolmer 5afcdbda65 app, server: Add an endpoint to fetch the frontend settings 2025-03-30 20:52:39 -05:00
Valentin Tolmer ba93533790 chore: update lldap/rust-dev to 1.85 2025-03-30 18:49:58 -05:00
Valentin Tolmer e4044b7415 dependencies: Upgrade sea-orm to 1.1.8 2025-03-30 23:00:50 +02:00
meetpatty 26b25e7776 example_configs: Fix nslcd group member mapping. 2025-03-29 11:44:17 +01:00
taiwan-king 20ade89633 example_configs: Add MFA with DuoAuthProxy 2025-03-13 23:28:16 +01:00
Yuki 928559890a example_configs: add example with podman quadlets for pgsql deployment w/ secrets 2025-03-13 17:46:57 +01:00
hendrik1120 049e882c35 docs(readme): clarify password change permission for admin users 2025-03-07 12:31:44 +01:00
MickMorley f5f3091313 example_configs: update Home Assistant
Found that only a restart of Home Assistant will read the new lines in configuration.yaml.  Also added a note to use `-k` when using the curl command if needed.
2025-02-27 17:30:51 +01:00
Simon Broeng Jensen 0a0f915ce6 chore: update rust-argon2 crate to v2 2025-02-25 15:18:06 +01:00
xeoneox 5f42d423e3 example_configs: fix typo in Stalwart config 2025-02-24 08:10:48 +01:00
Simon Broeng Jensen 2a226963ee auth: move Permission and ValidationResults to auth crate 2025-02-22 23:18:06 +01:00
Simon Broeng Jensen ca1c6ff645 domain-handlers: move backend handler traits to separate crate 2025-02-21 20:41:20 +01:00
xeoneox e22d17dca6 example_configs: reduce privileges for stalwart bind user
Update instructions for read_only bind
2025-02-21 16:02:08 +01:00
Simon Broeng Jensen f34fa1d701 cargo,server: update ldap3_proto to version 0.6.0 2025-02-21 11:27:13 +01:00
Simon Broeng Jensen d854ace89f domain-model: move domain::model module to separate crate 2025-02-21 10:25:07 +01:00
xeoneox 3c0359eb8a example_configs: Add Stalwart link to README 2025-02-20 21:16:32 +01:00
xeoneox b591539c8a example_configs: Add Stalwart
Failed at getting Docker Mailserver and Maddy working, so I wrote a config for Stalwart Mailserver instead
2025-02-20 20:50:52 +01:00
Simon Broeng Jensen 5d2f168554 domain + server: introduce new AttributeValue enum 2025-02-19 15:48:27 +01:00
JaidenW cf0e9a01f1 readme: add link to the discord bot 2025-02-19 08:03:35 +01:00
Giovanni Geraci 86d15e831e example_configs: Add Project Quay 2025-02-16 21:33:01 +01:00
Simon Broeng Jensen 8285e21ebb domain: rename AttributeValue to Attribute
Preparation for storing the actual types for each value, which
will repurpose the AttributeValue name.
2025-02-05 16:07:05 +01:00
Simon Broeng Jensen 4c6cfeee9e server: remove deprecated fields from CreateUserRequest
The fields first_name, last_name, and avatar have all been moved
to regular attributes in the database, and are available through
the GraphQL API as such as well. This commit removes the legacy
fields for each on the internal CreateUserRequest type, leaving
these to only be updateable through attributes.

The fields are still available in the GraphQL CreateUserInput
type, preserving backwards compatiblity, and if set, they will
be used for the corresponding attribute values. If both fields
and attributes are set, the values given through attributes will
superceed the fields, and be used. This change also fixes a bug,
where creation of a user would fail if either of these attributes
were set as both attribute and field, as it would attempt to
insert the attribute twice, violating a unique constraint in the
database.
2025-02-05 15:42:06 +01:00
Simon Broeng Jensen 37a683dcb2 validation: move validation crate to crates folder 2025-02-03 23:32:42 +01:00
Simon Broeng Jensen b5e87c7226 auth: move auth crate to crates folder 2025-02-03 23:32:42 +01:00
nitnelave dd0ba5975e server: Adds support for whoamiOID
Co-authored-by: eyjhb <eyjhbb@gmail.com>
2025-02-03 23:21:44 +01:00
Simon Broeng Jensen 1b26859141 server: move domain::types to separate domain crate (#1086)
Preparation for using basic type definitions in other upcoming
modules, in particular for plugins.
2025-02-03 23:00:27 +01:00
Fabian May 417abc54e4 server: Cleanup log messages for ignored attribute warnings
Reduce log messages by remove line break and remove visible \n\ sequence
2025-02-03 22:52:15 +01:00
Simon Broeng Jensen 5cc489aafe app: mute a clippy error about a wasm_bindgen directive 2025-01-29 13:32:05 +01:00
Simon Broeng Jensen c01c7744c7 server: fix a couple of clippy warnings 2025-01-29 13:32:05 +01:00
Simon Broeng Jensen 1b58ac61f4 server: fix serving of frontend after PR #1079 (#1090)
Had changed behaviour to serve the gz compressed wasm package
with the uncompressed handler.
2025-01-29 13:25:03 +01:00
tyami94 f46e5375df server: Allow custom path to front-end assets 2025-01-28 19:37:12 +01:00
Mathieu Bélanger 722464daf4 example_configs: Add pgAdmin 2025-01-22 22:12:54 +01:00
Simon Broeng Jensen 0799b6bc26 server: include preserved case in user attribute value search
Extends the generated UserRequestFilter with an OR'ed clause for
the attribute value in both it's original case and lowercased.
2025-01-22 10:37:04 +01:00
Simon Broeng Jensen f5fbb31e6e server, app: Add validation for attribute names (#1075)
This commit adds support for basic validation of attribute
names at creation, and also in the schema overview. Both
user and group attributes are validated with the same rules.

For now, attribute names will be considered valid, if they
only contain alphanumeric characters and dashes.

Validation has been added the following places:

- In graphql API, for creation of both user and group attributes.
  Request will be rejected, if attribute name is invalid.

- In frontend, before submitting a request to create a new user
  or group attribute. Rejection here will show an error message
  including a list of the invalid characters used.

As this change adds stricter validation to attributes, and, since
the rationale for this is partly compatibility with other LDAP
systems, this change also adds a warning in the schema overviews
to any attribute using invalid characters.
2025-01-22 09:57:47 +01:00
Simon Broeng Jensen 31a0cf5a4f app: Change default alias for User & Group schema attributes (#1082)
A number of the hardcoded attributes displayed in the User
and Group schema overviews were using aliases which contain
underscores, which is not always completely supported by
clients. Therefore, this commit changes the primary alias
for each attribute to be one without underscores.

To reduce confusion with this change, and also improve the
visibility of available aliases, this commit also includes
a list of each alias, for each hardcoded attribute. This
list will also include the old primary aliases.
2025-01-21 13:46:55 +01:00
Simon Broeng Jensen 33fb59f2f7 server: Add support for querying GroupId with LDAP filters 2025-01-20 17:07:53 +01:00
farshad fb43af1299 example_configs: update Authelia with LLDAP default settings 2025-01-19 07:02:05 +01:00
Valentin Tolmer f417427635 Prevent starting up if the JWT secret is not given
Similarly, don't create the admin if the password is not given
2024-12-24 19:40:26 +01:00
Dakota G 1f26262e13 example_configs: add Hashicorp 2024-12-10 07:34:50 +01:00
Zepmann 42fccf4713 readme: Updated Arch Linux install-from-repository section
Cleaned up the Arch Linux section. Added a link to the discussions support thread.
2024-12-07 18:49:58 +01:00
xeoneox 928faa4bcc example_configs: add search filter in onedev configuration 2024-12-07 07:17:52 +01:00
xeoneox 3895a5050d example_configs: Update OneDev example for latest release 2024-12-06 00:21:35 +01:00
Christian Medel f92035b6fd example_configs: Add Kimai 2024-11-25 22:20:09 +01:00
Valentin Tolmer 37a10c871f github: Fix release bot clearing the release body 2024-11-22 23:12:36 +01:00
Valentin Tolmer 8397d536d9 chore: bump version to 0.6.2-alpha 2024-11-22 22:55:53 +01:00
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
elmodor 1f2f034a48 Added maddy example config
Updated README.md for Maddy

i
2024-01-18 22:01:57 +01:00
Valentin Tolmer bd0a58b476 server: clean up the attributes, relax the substring filter conditions
This consolidates both user and group attributes in their map_{user,group}_attribute as the only point of parsing. It adds support for custom attribute filters for groups, and makes a SubString filter on an unknown attribute resolve to just false.
2024-01-17 23:44:25 +01:00
dependabot[bot] 4adb636d53 build(deps): bump actions/cache from 3 to 4
Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-17 22:11:09 +01:00
Valentin Tolmer 6f905b1ca9 server: update ldap3_proto dependency
This will fix the issue with some unhandled controls, this time for sure
2024-01-16 17:52:15 +01:00
Valentin Tolmer 2ea17c04ba server: Move the definition of UserId down to lldap_auth 2024-01-15 23:48:59 +01:00
Valentin Tolmer 10609b25e9 docs: Misc updates
Deprecate key_file in favor of key_seed, add a script to generate the secrets
2024-01-14 22:57:10 +01:00
Valentin Tolmer 9f8364ca1a server: Fix private key reset functionality 2024-01-14 22:54:13 +01:00
Valentin Tolmer 56078c0b47 docs: add lldap-cli references, improve README 2024-01-13 22:53:12 +01:00
Valentin Tolmer 8b7852bf1c chore: clippy warnings 2024-01-13 18:32:58 +01:00
Valentin Tolmer c4be7f5b6f server: Serialize attribute values when searching
This should fix #763 and allow filtering by custom attribute values.
2024-01-13 13:37:46 +01:00
Valentin Tolmer 337101edea server: update ldap3_proto dependency
This will fix the issue with some unhandled controls
2024-01-08 16:10:11 +01:00
Valentin Tolmer dc140f1675 server: exit with non-zero code when running into errors starting 2024-01-06 00:43:41 +01:00
Roman f74f88f0c0 example_configs: Add grocy 2024-01-03 21:46:14 +01:00
Valentin Tolmer 708d927e90 server: add a unique index to the memberships 2024-01-03 12:40:24 +01:00
Valentin Tolmer 0d48b7f8c9 server: add support for entryDN 2023-12-31 08:27:25 +01:00
Valentin Tolmer f2b1e73929 server: Add a check for a changing private key
This checks that the private key used to encode the passwords has not
changed since last successful startup, leading to a corruption of all
the passwords. Lots of common scenario are covered, with various
combinations of key in a file or from a seed, set in the config file or
in an env variable or through CLI, and so on.
2023-12-29 15:37:52 +01:00
Dedy Martadinata S 997119cdcf switch up build steps (#776)
* switch up build steps

* also swith the buildx
2023-12-29 00:23:57 +07:00
ddiawara a147085a2f example_configs: add Dovecot configuration for docker-mailserver
---------

Co-authored-by: Dedy Martadinata S <dedyms@proton.me>
2023-12-28 11:26:37 +01:00
Dedy Martadinata S f363ff9437 docker: Add a rootless container
New images with "-rootless" tags will automatically get released on the docker registry.
2023-12-28 11:22:20 +01:00
Haoyu Xu b6e6269956 example_configs: make the zitadel doc more comprehensive
fixed `Userbase` attribute; added `Preferred username attribute`; added `Automatic creation`
2023-12-25 18:48:07 +01:00
Valentin Tolmer ff0ea51121 server: Add an option to force reset the admin password 2023-12-22 08:27:35 +01:00
Haoyu Xu 9ac96e8c6e example_configs: add support for admins and local users in homeassistant 2023-12-19 22:36:00 +01:00
Haoyu Xu 63f802648f example_configs: Add zitadel 2023-12-19 22:11:21 +01:00
Valentin Tolmer 1aba962cd3 readme: Fix block quote 2023-12-19 13:42:07 +01:00
Dedy Martadinata S 06697a5305 readme: Add installation from package 2023-12-19 13:34:26 +01:00
Sematre 5a5d5b1d0e example_configs: Add GitLab 2023-12-17 22:46:02 +01:00
Cherryblue 2e0d65e665 example_configs: Update seafile.md for v11
Updating the guide for Seafile v11+, to mention the differences.
2023-12-16 09:08:30 +01:00
Valentin Tolmer 2c54ad895d chore: clippy 2023-12-15 23:37:25 +01:00
Valentin Tolmer 272c84c574 server: make attributes names, group names and emails case insensitive
In addition, group names and emails keep their casing
2023-12-15 23:21:22 +01:00
dependabot[bot] 71d37b9e5e build(deps): bump actions/download-artifact from 3 to 4
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-14 22:08:22 +01:00
dependabot[bot] c55e0f3bcf build(deps): bump actions/upload-artifact from 3 to 4
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-14 21:55:41 +01:00
Nicholas Malcolm f2946e6cf6 docs: Fix the Bootstrap script skipping similar name groups
Existing logic used jq's contain which confusingly will do partial string matches. For example a group named "media_admin" will be created then "media" will be skipped saying it already exists.
2023-12-12 04:22:28 +01:00
jakob42 f3e2f8c52d example_configs: Add Kasm configuration example 2023-12-11 10:53:53 +01:00
MinerSebas 70d85524db app: make it possible to serve lldap behind a sub-path 2023-12-07 18:21:49 +01:00
Mohit Raj ec0737c58a docs(config): clarify docker networking setup 2023-12-03 15:10:51 +01:00
Yevhen Kolomeiko 33f50d13a2 example_configs(bootstrap.sh): Add check is user in group 2023-11-30 11:06:16 +01:00
null 5cd4499328 chore(docs): update jenkins.md
Use the correct Manager DN.
2023-11-23 05:59:35 +01:00
Christian Medel a65ad14349 example_configs: Add Mastodon and Traccar 2023-11-20 22:05:06 +01:00
Zepmann 2ca5e9e720 Readme: add AUR installation instructions 2023-11-17 07:16:59 +01:00
Valentin Tolmer 4f72153bd4 server: Disallow deleting hardcoded attributes 2023-11-05 16:19:04 +01:00
Valentin Tolmer 829c3f2bb1 server: Prevent regular users from modifying non-editable attributes 2023-11-05 16:06:45 +01:00
themartinslife a6481dde56 example_configs: add a Jenkins config 2023-11-04 15:41:36 +01:00
Yevhen Kolomeiko 35146ac904 example_configs: Add bootstrap script 2023-11-02 20:49:15 +01:00
Cherryblue d488802e68 example_configs: Fix display name in wikijs.md
Correction of the display name alias for it to work with wikijs.
2023-11-01 10:23:06 +01:00
nitnelave 927c79bb55 github: Create issue templates 2023-10-30 22:58:52 +01:00
Valentin Tolmer 3b6f24dd17 github: Add CONTRIBUTING guidelines 2023-10-30 22:40:56 +01:00
Valentin Tolmer 8ab900dfce github: update postgres migration sed to handle jwt_storage 2023-10-30 21:59:48 +01:00
Valentin Tolmer 504227eb13 server: Add JWTs to the DB
Otherwise, logging out doesn't actually blacklist the JWT
2023-10-30 21:59:48 +01:00
Hobbabobba 1b97435853 example_configs: Add a working admin user for dokuwiki (#720) 2023-10-30 13:38:13 +01:00
Valentin Tolmer 1fddd87470 server: Simplify RequestFilter's TryInto 2023-10-30 11:31:04 +01:00
dependabot[bot] af8277dbbd build(deps): bump docker/login-action from 2 to 3
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-30 10:05:11 +01:00
dependabot[bot] 609d0ddb7d build(deps): bump docker/metadata-action from 4 to 5
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 4 to 5.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Upgrade guide](https://github.com/docker/metadata-action/blob/master/UPGRADE.md)
- [Commits](https://github.com/docker/metadata-action/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-26 13:34:27 +02:00
dependabot[bot] 3df42ae707 build(deps): bump docker/setup-qemu-action from 2 to 3
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-26 08:25:13 +02:00
dependabot[bot] 8f9520b640 build(deps): bump actions/checkout from 4.0.0 to 4.1.1 (#716)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.0.0 to 4.1.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.0.0...v4.1.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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-26 04:19:27 +02:00
dependabot[bot] 7c9f61e2eb build(deps): bump docker/build-push-action from 4 to 5 (#677)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

---
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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-26 03:42:52 +02:00
dependabot[bot] 5275af8f96 build(deps): bump docker/setup-buildx-action from 2 to 3 (#676)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: nitnelave <valentin@tolmer.fr>
2023-10-25 19:55:03 +02:00
Andrew Roberts 0db41f6278 docker: add date-based tagging to matrix jobs 2023-10-23 08:34:24 +02:00
Florian 4574538c76 clippy: fix warning for unwrap_or_default 2023-10-22 20:34:31 +02:00
Florian 9d5714ee0b chore: update repository references 2023-10-22 19:59:36 +02:00
Valentin Tolmer c6ecf8d58a server: Add graphql support for setting attributes 2023-10-22 16:34:15 +02:00
MI3Guy 9e88bfe6b4 docs: fix primary key in PG migration
When importing data, Postgres doesn't update the auto increment counter for the groups. Creating a group after an import would fail due to duplicate IDs. This manually sets the ID to the max of the IDs + 1.
2023-10-09 16:35:52 +02:00
Simon Broeng Jensen 5bd81780b3 server: Add basic support for Paged Results Control (RFC 2696)
This implements rudimentary support for the Paged
Results Control.

No actual pagination is performed, and we ignore
any requests for specific window sizes for paginated
results.

Instead, the full list of search results is returned
for any searches, and a control is added to the
SearchResultsDone message, informing the client that
there is no further results available.
2023-10-06 13:52:05 +02:00
Simon Broeng Jensen 4fd71ff02f example_configs: Add Apereo CAS Server 2023-10-04 15:02:19 +02:00
dependabot[bot] f0046692b8 build(deps): bump webpki from 0.22.1 to 0.22.2
Bumps [webpki](https://github.com/briansmith/webpki) from 0.22.1 to 0.22.2.
- [Commits](https://github.com/briansmith/webpki/commits)

---
updated-dependencies:
- dependency-name: webpki
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-04 02:19:09 +02:00
Valentin Tolmer 439fde434b server: Add graphql support for creating/deleting attributes 2023-10-04 02:07:04 +02:00
Valentin Tolmer 2a5fd01439 server: add support for creating a group with attributes 2023-09-29 02:31:20 +02:00
Valentin Tolmer 2c398d0e8e server: Add domain support for creating/deleting attributes 2023-09-29 02:31:20 +02:00
Valentin Tolmer 93e9985a81 server: rename SchemaBackendHandler -> ReadSchemaBackendHandler 2023-09-29 02:31:20 +02:00
stuart938503 ed3be02384 lldap_set_password: Add option to bypass password requirements 2023-09-28 22:39:50 +02:00
Valentin Tolmer 3fadfb1944 server: add support for creating a user with attributes 2023-09-25 01:57:24 +02:00
Valentin Tolmer 81204dcee5 server: add support for updating user attributes 2023-09-25 01:57:24 +02:00
Valentin Tolmer 39a75b2c35 server: read custom attributes from LDAP 2023-09-15 15:26:18 +02:00
Valentin Tolmer 8e1515c27b version: bump to 0.5.1-alpha 2023-09-15 00:52:33 +02:00
Valentin Tolmer ddfd719884 readme: Update references to nitnelave/lldap to lldap/lldap 2023-09-15 00:28:01 +02:00
Valentin Tolmer 6f04530700 release: 0.5.0 2023-09-14 20:36:32 +02:00
Valentin Tolmer caf67fdf2b server: Ensure uuid version is at least 1 2023-09-14 20:36:32 +02:00
Valentin Tolmer 034794d58d server: return user-defined attributes for groups in graphql
Part of #67
2023-09-14 13:02:45 +02:00
Valentin Tolmer e53ce92c96 server: return attributes in graphql
Progress for #67
2023-09-13 22:52:08 +02:00
Charles van Niman 630ac5fd8c example_configs: Add proxmox 2023-09-13 15:14:53 +02:00
Ishan Jain b269fa0fc7 example_configs: Add thelounge configuration example 2023-09-13 00:29:29 +02:00
Valentin Tolmer 208cc7192e server: Only create the default admin if there are no admins
Fixes #563
2023-09-12 15:54:18 +02:00
MrRulf 80e9145a4f example_configs: Update nextcloud_oidc_authelia.md
Corrected a base URL, points at the nextcloud instance now instead of the authentificator.
Also added additional information for different nextcloud URL configurations.
2023-09-12 14:16:39 +02:00
Valentin Tolmer 78d370d3f4 app: Add a button to clear the avatar 2023-09-12 13:28:46 +02:00
Valentin Tolmer f279a14693 github: add release bot 2023-09-11 17:49:37 +02:00
Valentin Tolmer b54bf3c4d5 server: clean up database-mapped types 2023-09-11 17:09:49 +02:00
Valentin Tolmer 582abba793 server: clean up user query
With the new find_with_linked from sea_orm
2023-09-11 17:09:49 +02:00
Valentin Tolmer 94da42ffb9 server: small cleanup 2023-09-11 17:09:49 +02:00
Valentin Tolmer 08d3aef177 server: Update sea-orm, strum 2023-09-11 17:09:49 +02:00
Valentin Tolmer 7671b61a6b server: Add support for querying the OUs 2023-09-11 00:58:15 +02:00
dependabot[bot] 47b308f9b7 build(deps): bump actions/checkout from 3.6.0 to 4.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.6.0 to 4.0.0.
- [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/v3.6.0...v4.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-11 00:35:29 +02:00
dependabot[bot] 1a5931c3df build(deps): bump webpki from 0.22.0 to 0.22.1
Bumps [webpki](https://github.com/briansmith/webpki) from 0.22.0 to 0.22.1.
- [Commits](https://github.com/briansmith/webpki/commits)

---
updated-dependencies:
- dependency-name: webpki
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-10 22:43:24 +02:00
Valentin Tolmer b3d771e063 server: fix clippy warnings 2023-09-10 22:29:32 +02:00
Valentin Tolmer 134796aa9f server: Switch tests to pretty_assertions 2023-09-10 22:29:32 +02:00
Valentin Tolmer 1598f096e9 server: Upgrade ldap3_proto 2023-09-10 22:29:32 +02:00
Valentin Tolmer 99ed6eface server: Update tracing-forest and take advantage of the span fields 2023-09-10 22:29:32 +02:00
Valentin Tolmer ce6bf7c548 cargo: Fix getrandom dependency 2023-09-10 22:29:32 +02:00
Charles van Niman 5677ff798f example_configs: add Pdns admin example 2023-08-31 10:32:56 +02:00
Charles van Niman e47004097a example_configs: Add MinIO configuration 2023-08-31 10:25:49 +02:00
Dedy Martadinata S 5e3a4f3446 github: enable armv7 musl build 2023-08-28 10:42:48 +02:00
dependabot[bot] 8e61ee60d5 build(deps): bump actions/checkout from 3.5.3 to 3.6.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.5.3 to 3.6.0.
- [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/v3.5.3...v3.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-08-25 15:00:13 +02:00
nitnelave a426453d7f github: Update rustc version to 1.72, switch to lldap/rust-dev (#657) 2023-08-25 13:50:53 +02:00
Masgalor 1ac9bd0e68 example_configs: Add example config for zulip (#655) 2023-08-24 11:31:36 +02:00
poVoq a83c305e51 readme: Add links to ergo and thelounge 2023-08-15 15:08:10 +02:00
poVoq 7b171cf59a example_configs: Add ergo.md
For the ergo.chat IRC server
2023-08-15 14:45:11 +02:00
Jacob b237c71b99 example_configs: Add LibreNMS 2023-08-13 20:40:07 +02:00
poVoq 2eff37684d example_configs: Add config for The Lounge 2023-08-11 07:23:14 +02:00
Chris 836823a5cd Add Zabbix Web example 2023-08-10 20:13:51 +02:00
Chris e1d4df0b04 example_configs: Update script names in home-assistant.md (#644)
The name of the script did not match the example code and tripped me up until I noticed.
2023-08-10 17:25:39 +02:00
Valentin Tolmer 70bbe7f5ad app: Add the JS warnings to index_local.html 2023-08-05 22:05:30 +02:00
Daniel 6d796df097 app: Add messages to warm any Incompatible browser
Fixes #639
2023-08-05 11:25:55 +02:00
Valentin Tolmer 6cd6b412fe tests: Use an env variable for the private seed 2023-08-04 17:31:22 +02:00
Valentin Tolmer 042429a11d github: fix linguist attributes 2023-08-04 16:09:28 +02:00
Samuel Lorch c440df631f example_configs: Add Jellyfin Password change 2023-08-03 10:56:17 +02:00
Valentin Tolmer 3247ffc8ea github: only run the coverage after the tests 2023-08-03 10:32:45 +02:00
Valentin Tolmer ef17c280b1 server: fix smtp encryption parsing 2023-08-03 09:54:12 +02:00
Valentin Tolmer d0cdfa97c7 server: Add a message ID to sent emails
Fixes #608
2023-08-02 15:34:13 +02:00
Valentin Tolmer f0bbcfd2c8 set_password: Properly parse the URL, support trailing slashes
Fixes #597
2023-08-02 13:36:22 +02:00
Valentin Tolmer 08b7c6ce33 server: Allow creating a user with multiple objectClass
Fixes #612
2023-08-02 12:15:49 +02:00
Valentin Tolmer 719708dfd0 server: Wrap a lettre error with a friendlier error 2023-08-02 10:38:14 +02:00
Valentin Tolmer b82cb83318 server: Fix env variable for smtp_encryption
Fixes #611
2023-08-02 10:38:14 +02:00
Valentin Tolmer d9f4adcb0e ldap: Add support for modifying the password with a modify operation 2023-07-29 12:39:23 +02:00
Valentin Tolmer e5bc06a617 graphql: sort the groups before returning them 2023-07-29 11:27:50 +02:00
Austin Eschweiler af49871801 example_configs: Add tandoor recipes 2023-07-27 18:12:36 +02:00
Dedy Martadinata S 7d1f5abc13 dev image: prep for 1.71 (#586)
* Update Dockerfile.dev

* Remove nodejs
* Remove gnu deps
* Add env targeting musl gcc binary
2023-07-18 08:40:18 +07:00
Valentin Tolmer 31a8ba24a0 server,graphql: Add a GraphQL method to get the schema 2023-07-10 17:18:33 +02:00
Valentin Tolmer 9e1b58d033 server,ldap: add encoding for lists and integers 2023-07-10 17:18:33 +02:00
Hobbabobba 1acc8cd78c example_configs: Add squid 2023-07-01 21:49:31 +02:00
Valentin Tolmer 3140af63de server: Use schema to populate attributes 2023-06-29 11:11:20 +02:00
Valentin Tolmer 829ebf59f7 server: Add SchemaBackendHandler trait 2023-06-29 11:11:20 +02:00
Alistair Chapman 4ce145bac2 example_configs: Update Keycloak example for name attributes
Keycloak seems to default to "First name" being `cn` which LLDAP uses for Display Name, resulting in Users getting duplicated display names in Keycloak (like First Last Last), or missing their first name entirely (when they have no DIsplay Name in LLDAP).

This just updates the example config to provide instructions on changing the attribute mapping in Keycloak to fix this.
2023-06-22 10:08:23 +02:00
Mesar Hameed 6ef229f3d0 app: Fix typos that broke accessibility labels 2023-06-18 17:08:54 +02:00
Valentin Tolmer 19b4fd520a scripts: don't export the default attribute schemas 2023-06-15 15:00:52 +02:00
Valentin Tolmer 70146e0b70 server: prepare DB schema for user attributes
First step of #67.
2023-06-14 23:20:37 +02:00
Mitchell Currie a804368806 Make it more obvious LDAPS is supported (#461)
Update example compose config showing both port and alluding to the environment variable that controls the certificates

Co-authored-by: Dedy Martadinata S <dedyms@proton.me>
2023-06-14 23:32:35 +07:00
Dedy Martadinata S 3ec42fffaa actions: update mariadb healthcheck 2023-06-14 15:14:03 +02:00
dependabot[bot] 95727335a7 build(deps): bump actions/checkout from 3.5.2 to 3.5.3 (#601)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.5.2 to 3.5.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/v3.5.2...v3.5.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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-10 08:03:09 +09:00
Gareth Dunstone 79f9a3a5c2 Update jellyfin.md example (#598)
* Update jellyfin.md example

The LdapAdminBaseDN either doesn't work properly or is used incorrectly here.

This change will make it work.

see: 
- https://github.com/jellyfin/jellyfin-plugin-ldapauth/issues/145

* Update jellyfin.md

Added some more detail about admin groups and user groups.
2023-06-09 09:59:40 +07:00
arcoast 7daebc308b example_configs: Add Mealie configuration 2023-06-07 13:29:41 +02:00
nitnelave 50017cff36 github: create FUNDING.yml 2023-06-07 11:07:13 +09:00
Howard He f812c9e666 example_configs: fix grafana config 2023-06-02 05:39:47 +02:00
dalz 87a35af693 example_configs: fix dokuwiki config
Previously Dokuwiki couldn't find LDAP groups.
2023-05-21 19:16:26 +09:00
lordratner 4c4a397f66 example_configs: fix typo in home-assistant.md
Error in file name /config/lldap-auth.sh
2023-05-14 10:03:32 +09:00
nitnelave d720a7812a cargo: set metadata for publishing crates (#577) 2023-05-12 18:06:06 +07:00
nitnelave d2dec56cca readme: add buymeacoffee link 2023-05-12 16:21:09 +09:00
poVoq ab2da7b975 example_configs: Add Ejabberd
Basic auth only for now
2023-05-09 14:31:54 +02:00
Anudeep 8f69e4badd example_configs: add chmod to home-assistant instructions 2023-05-06 07:08:41 +02:00
Valentin Tolmer 5bd00f24a2 docker: ignore more files 2023-05-02 16:15:54 +02:00
Valentin Tolmer ab9ee8d962 tests: allow dead code in common module
We're running afoul of https://github.com/rust-lang/rust/issues/46379,
where each test is compiled independently, so any test that doesn't use
every helper method triggers a dead code warning.
2023-05-02 16:15:54 +02:00
lordratner 852e1586e7 example_configs: Fix a filter in Grafana 2023-05-02 15:45:04 +02:00
Herwig Hochleitner 23b388f3b8 docs: correct env var names in docker template toml 2023-05-02 15:40:16 +02:00
Hobbabobba 22ae2c7124 example_configs: fix zendto memberrole 2023-04-25 20:53:38 +02:00
lordratner 5ad63d31d3 example_docs: add pfsense.md 2023-04-20 18:10:14 +02:00
Tyler Pace d55d4487ed Add OPNsense example config. (#558)
* Add OPNsense example config.
---------

Co-authored-by: Tyler Pace <tpace@newrelic.com>
2023-04-15 10:06:34 +07:00
Austin Alvarado 4283d27da6 server: Initial stab at e2e tests (#534)
Initial end to end testing. generates unique names for user and groups, and all tests run serially
2023-04-14 11:45:15 -06:00
Valentin Tolmer 4576cf9f2c bump: bump the version to 0.5-alpha, since we have a breaking change 2023-04-14 17:36:04 +02:00
Valentin Tolmer d1d5d38b32 server: fix incorrect logging 2023-04-14 17:02:00 +02:00
Valentin Tolmer e5ce98c874 server: Improve the error message in case of duplicate emails 2023-04-14 17:02:00 +02:00
Valentin Tolmer 96b7dbb1c5 server: Make key_seed a secret value 2023-04-14 00:07:54 +02:00
dependabot[bot] 9408b12bc7 build(deps): bump actions/checkout from 3.5.1 to 3.5.2
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.5.1 to 3.5.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/v3.5.1...v3.5.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>
2023-04-13 23:18:22 +02:00
Valentin Tolmer 4e85a4718f server: enforce email and uuid unicity 2023-04-13 17:51:49 +02:00
Valentin Tolmer d1f1eb8e80 config: Explicit the env variables 2023-04-13 09:22:09 +02:00
Valentin Tolmer da364746c4 server: Derive the server key from a seed
Fixes #504.
2023-04-13 09:17:05 +02:00
dependabot[bot] d672f68049 build(deps): bump actions/checkout from 3.5.0 to 3.5.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.5.0 to 3.5.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/v3.5.0...v3.5.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-04-13 09:03:27 +02:00
Valentin Tolmer dcca768b6c github: Add more folders to ignore for coverage 2023-04-11 17:17:28 +02:00
Valentin Tolmer ea69b4bead version: bump to 0.4.4-alpha 2023-04-11 17:17:28 +02:00
Valentin Tolmer 7b4188a376 cargo: update cargo.lock 2023-04-11 17:17:28 +02:00
Valentin Tolmer 252132430c github: always generate artifacts for a release 2023-04-11 15:01:41 +02:00
Valentin Tolmer 7f9bc95c5c release: 0.4.3 2023-04-11 14:41:57 +02:00
Valentin Tolmer 69fca82a86 readme: fix the codecov badge 2023-04-11 14:12:17 +02:00
Valentin Tolmer 9a30cac7b0 healthcheck: check that the server's certificate is the one in the config 2023-04-11 13:51:02 +02:00
Michał Mrozek 558bb37354 server: add support for ec private keys 2023-04-11 10:57:25 +02:00
Dedy Martadinata S 5b74852193 github: Leverage metadata (#532) 2023-04-11 11:03:56 +07:00
Valentin Tolmer d18cf1ac37 server: decode graphql parameter 2023-04-10 19:10:42 +02:00
Valentin Tolmer 96f55ff28e github: use codecov token only for main push, no token for PRs 2023-04-10 17:29:36 +02:00
Dedy Martadinata S 825f37d360 github: add healthcheck to the test DB services 2023-04-10 17:09:54 +02:00
Austin Alvarado 8eb27c5267 docs: Create Home Assistant config (#535) 2023-04-07 14:59:21 -06:00
budimanjojo 18d9dd6ff9 github: also push to ghcr.io and add docker.io/lldap/lldap 2023-04-05 17:51:23 +02:00
Austin Alvarado 308521c632 Use jq in CI to extract json deterministically (#529)
cut relies on the string being a fixed length, which is subject to change in the  future
2023-04-05 22:20:15 +07:00
Valentin Tolmer 86b2b5148d server: remove default value for SMTP user
Otherwise, not setting the user would default to "admin", which breaks
the unauthenticated workflow. No user specified should mean unauthenticated.

Fixes #520.
2023-04-04 16:27:44 +02:00
Valentin Tolmer b9e0e4a6dc version: bump cargo.lock 2023-04-04 16:27:44 +02:00
nitnelave 1b8849ead1 version: bump to 0.4.3-alpha (#522) 2023-04-04 13:00:17 +02:00
amiga23 1fe635384f docs: Add email attribute to nextcloud config
Otherwise nextcloud will not set the email address in users profile
2023-04-04 12:14:41 +02:00
Hobbabobba df16d66753 added Shaarli configuration example (#519)
* Create shaarli.md

* added Shaarli doc

* fixed uid
2023-04-03 18:54:39 +02:00
nitnelave 65e2c24928 github: Add CODEOWNERS 2023-03-31 10:42:53 +02:00
Austin Alvarado c4b8621e2a app: Fix password reset redirection (#513)
* Fix password reset redirection
* Add password reset enable flag
2023-03-30 09:47:41 -06:00
Valentin Tolmer 88a9f8a97b github: fix github_ref reference 2023-03-28 20:59:38 +02:00
Valentin Tolmer fc91d59b99 github: Don't skip rebuilding a docker image on main because it was built on a branch 2023-03-28 19:34:43 +02:00
Valentin Tolmer aad4711056 app: server uncompressed WASM to webkit browsers 2023-03-28 17:33:13 +02:00
Dedy Martadinata S c7c6d95334 docker: Add DB migration tests in the CI 2023-03-28 13:59:23 +02:00
Valentin Tolmer 84b4c66309 cargo: Update Cargo.lock with latest release 2023-03-28 12:10:04 +02:00
Valentin Tolmer 923d77072b gitattributes: Tag folders as docs, generated or ignored for linguist 2023-03-28 12:10:04 +02:00
Austin Alvarado 758aa7f7f7 docs: Fix md links 2023-03-27 18:08:27 +02:00
Valentin Tolmer 866a74fa29 github: Reduce actions trigger on metadata updates 2023-03-27 16:52:34 +02:00
Valentin Tolmer 36a51070b3 docker: ignore README 2023-03-27 16:52:34 +02:00
Valentin Tolmer 585b65e11d README: Add details about other DBs, migrations 2023-03-27 14:12:00 +02:00
Valentin Tolmer 2c8fe2a481 Revert "workflows: allow action to upload artifacts"
This reverts commit 1b67bad270.
2023-03-27 13:53:21 +02:00
Valentin Tolmer 1b67bad270 workflows: allow action to upload artifacts 2023-03-27 12:45:11 +02:00
Valentin Tolmer afe91c7cc0 release: 0.4.2 2023-03-27 11:07:24 +02:00
Valentin Tolmer bd1b7e8809 server: update base64 2023-03-27 10:46:16 +02:00
Valentin Tolmer ae9b04d4d2 worflows: add codecov token 2023-03-27 10:02:47 +02:00
dependabot[bot] bd6184554a build(deps): bump actions/checkout from 3.4.0 to 3.5.0 (#494)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.4.0 to 3.5.0.
- [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/v3.4.0...v3.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-27 09:14:26 +02:00
Austin Alvarado 834d68a47e docs: fix DB migration, add sqlite migration helper script 2023-03-22 17:07:10 +01:00
Austin Alvarado 05dbe6818d server: Create schema command 2023-03-21 14:16:19 +01:00
Austin Alvarado 80dfeb1293 app: Implement dark theme and toggle 2023-03-21 10:50:17 +01:00
Valentin Tolmer bf64c091cc docker: Update dockerfiles to build the lldap_set_password tool 2023-03-21 00:03:09 +01:00
Valentin Tolmer b4d7ada317 lldap_set_password: create the new tool
Fixes #473.
2023-03-21 00:03:09 +01:00
Valentin Tolmer a07f7ac389 server: ensure first/last name nullable, make avatar long blob in DB
Fixes #474, #486.
2023-03-20 23:42:47 +01:00
Valentin Tolmer 46b8f2a8a5 server: return groups in memberof by cn instead of uid
Fixes #468.
2023-03-20 22:10:38 +01:00
Austin Alvarado 91ada70c7d vscode: Update devcontainer and build instructions 2023-03-20 21:29:54 +01:00
Valentin Tolmer b2cfc0ed03 app: update yew to 0.19
This is a massive change to all the components, since the interface
changed.

There are opportunities to greatly simplify some components by turning
them into functional_components, but this work has tried to stay as
mechanical as possible.
2023-03-20 12:11:34 +01:00
Valentin Tolmer 8d44717588 app: replace ConsoleService with gloo_console 2023-03-20 12:11:34 +01:00
Valentin Tolmer f44e8b7659 app: wrap template arguments in braces
To prepare for the migration to yew 1.19
2023-03-20 12:11:34 +01:00
amiga23 07523219d1 docs(dex): Fix group search
The userAttr needs to be the full DN, otherwise the search does not work:
```
❯ ldapsearch -x -H ldap://localhost:3890 -D "cn=admin,ou=people,dc=example,dc=com" -b "ou=groups,dc=example,dc=com" -W "member=bob"
Enter LDAP Password: 
# extended LDIF
#
# LDAPv3
# base <ou=groups,dc=example,dc=com> with scope subtree
# filter: member=bob
# requesting: ALL
#

# search result
search: 2
result: 53 Server is unwilling to perform
text: Unsupported group filter: while parsing a user ID: Missing DN value

# numResponses: 1
```
2023-03-18 00:07:40 +01:00
dependabot[bot] 7f76e2095d build(deps): bump actions/checkout from 3.3.0 to 3.4.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.3.0 to 3.4.0.
- [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/v3.3.0...v3.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-17 18:18:13 +01:00
Valentin Tolmer 313fe3e0b7 clippy: fix new warning 2023-03-17 18:13:10 +01:00
Austin Alvarado c817b31dfc docs: Add DB migration docs 2023-03-17 17:49:24 +01:00
Dedy Martadinata S 9e038f5218 docker: use correct username for chown 2023-03-17 16:23:53 +01:00
Valentin Tolmer 9e479d38fe app: get rid of rollup, gzip the wasm 2023-03-05 16:31:56 +01:00
Valentin Tolmer 2593606f16 docs: add docs about scripting 2023-03-03 16:04:08 +01:00
Valentin Tolmer 1b91cc8ac2 server: update clap and mockall 2023-03-02 10:51:38 +01:00
Valentin Tolmer 28607c4744 server: update various dependencies 2023-03-02 10:51:38 +01:00
Valentin Tolmer dce73f91ef server: update actix, inline juniper-actix 2023-03-02 10:51:38 +01:00
Valentin Tolmer 07de6062ca server: update tokio 2023-03-02 10:51:38 +01:00
Valentin Tolmer c9997d4c17 server: statically enforce access control 2023-03-01 11:28:04 +01:00
Luca Tagliavini 322bf26db5 server: allow non authenticated smtp connections 2023-02-25 18:56:49 +01:00
carolosf 98acd68f06 example_configs: Add example for Sonatype Nexus Repository Manager 3 2023-02-23 09:33:35 +01:00
WS 733f990858 example_configs: Add Rancher example 2023-02-20 15:27:00 +01:00
Valentin Tolmer bebb00aa2e app: improve error message for wrong/expired reset token 2023-02-15 14:43:26 +01:00
Valentin Tolmer 193a0fd710 server: Remove trailing / from the domain URL 2023-02-15 14:43:26 +01:00
Valentin Tolmer 3650a438df docker: fix healthcheck 2023-02-15 11:10:32 +01:00
arcoast 5bee73180d example_configs: add authentik configuration
This should import users, groups & memberships
2023-02-14 18:22:49 +01:00
Valentin Tolmer 672dd96e7e server: add content-type header to the email 2023-02-14 11:22:22 +01:00
Valentin Tolmer 62104b417a app: probe for password reset support 2023-02-13 20:24:20 +01:00
Valentin Tolmer 562ad524c4 server: only add password reset routes if they are enabled 2023-02-13 20:24:20 +01:00
Valentin Tolmer ea498df78b server: add a test for compare with uniqueMember 2023-02-13 19:31:12 +01:00
Valentin Tolmer 1ce239103c server: removed dbg 2023-02-13 16:14:52 +01:00
Valentin Tolmer 81036943c2 server: Add support for SubString ldap filter 2023-02-13 16:10:14 +01:00
Valentin Tolmer 21e51c3d38 server: Add support for LdapCompare op 2023-02-13 12:59:53 +01:00
DarkSpir e92947fc3b app: Change input field to password type in change_password ui (#443)
Change input field type for field old_password from its default "text" to "password"

Fixes #442
2023-02-13 09:29:54 +01:00
Juli 94d45f7320 example_configs: Added explanation to Jellyfin Docs 2023-02-12 11:10:52 +01:00
Valentin Tolmer d04305433f server: use the new into_tuple from sea_orm 2023-02-10 12:57:38 +01:00
Valentin Tolmer 63cbf30dd7 server: upgrade sea-orm to 0.11 2023-02-10 12:57:38 +01:00
Valentin Tolmer 96eb17a963 server: fix clippy warning
The clippy::uninlined_format_args warning in 1.67 was downgraded to
pedantic in 1.67.1 due to lack of support in rust-analyzer, so we're not
updating that one yet.
2023-02-10 12:03:23 +01:00
Valentin Tolmer 8f2c5b397c server: allow NULL for display_name
Fixes #387.
2023-02-10 11:19:22 +01:00
Rex Zhang 648848c816 example_configs: Add note for Gitea's simple auth mode 2023-02-08 10:30:23 +01:00
Diptesh Choudhuri 58b9c28a0b example_configs: Add Dex example
Fixes #428.
2023-02-01 13:02:52 +01:00
dependabot[bot] c3d18dbbe8 build(deps): bump docker/build-push-action from 3 to 4
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

---
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>
2023-01-31 11:26:05 +01:00
dependabot[bot] 1e6a0edcfb build(deps): bump bumpalo from 3.10.0 to 3.12.0
Bumps [bumpalo](https://github.com/fitzgen/bumpalo) from 3.10.0 to 3.12.0.
- [Release notes](https://github.com/fitzgen/bumpalo/releases)
- [Changelog](https://github.com/fitzgen/bumpalo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fitzgen/bumpalo/compare/3.10.0...3.12.0)

---
updated-dependencies:
- dependency-name: bumpalo
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-24 14:55:43 +01:00
Valentin Tolmer d56de80381 server: Update lettre 2023-01-24 14:39:58 +01:00
Valentin Tolmer 3fa100be0c server: update sea-orm dependency
Fixes #405
2023-01-24 10:51:19 +01:00
Dedy Martadinata S df1169e06d docker: simplify binary build, add db integration test 2023-01-22 11:10:26 +01:00
arcoast 0ae1597ecd example_configs: Add Wikijs example
In response to https://github.com/nitnelave/lldap/pull/424#discussion_r1083280235
2023-01-22 09:49:00 +01:00
Igor Rzegocki d722be8896 server: add option to use insecure SMTP connection 2023-01-19 11:30:25 +01:00
Valentin Tolmer 9018e6fa34 server, refactor: Add a conversion from bool for the filters 2023-01-17 15:09:06 +01:00
Luca Tagliavini 807fd10d13 server: Add support for DN filters 2023-01-17 14:21:57 +01:00
Valentin Tolmer f979e16b95 server: Fix healthcheck return code
The healthcheck was not returning a non-zero code when failing, due to
an extra layer of Results
2023-01-16 17:35:08 +01:00
Valentin Tolmer 955a559c21 clippy: fix warning 2023-01-13 15:50:03 +01:00
Valentin Tolmer e458aca3e3 db: Change the DB storage type to NaiveDateTime
The entire internals of the server now work using only NaiveDateTime,
since we know they are all UTC. At the fringes (LDAP, GraphQL, JWT
tokens) we convert back into UTC to make sure we have a clear API.

This allows us to be compatible with Postgres (which doesn't support
DateTime<UTC>, only NaiveDateTime).

This change is backwards compatible since in SQlite with
Sea-query/Sea-ORM, the UTC datetimes are stored without a timezone, as
simple strings. It's the same format as NaiveDateTime.

Fixes #87.
2023-01-13 15:50:03 +01:00
Valentin Tolmer 692bbb00f1 db: Change the version number from u8 to i16
This is the smallest integer compatible with all of MySQL, Postgres and
SQlite.

This is a backwards-compatible change for SQlite since both are
represented as "integer", and all u8 values can be represented as i16.
2023-01-13 15:50:03 +01:00
poVoq 260b545a54 example_configs,gitea: add additional attributes and group sync
Not extensively tested, but group/team sync seems to work in Forgejo.
2023-01-09 17:53:44 +01:00
Dedy Martadinata S 3a43b7a4c2 docker: simplify ci and better package release artifacts 2023-01-06 16:34:22 +01:00
dependabot[bot] c87adfeecc build(deps): bump actions/checkout from 3.2.0 to 3.3.0 (#410)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.2.0 to 3.3.0.
- [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/v3.2.0...v3.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-06 13:13:54 +01:00
Dedy Martadinata S d7cc10fa00 ci: fetch missing web components 2023-01-05 15:36:01 +01:00
Austin Alvarado 14531fa258 docker: upgrade alpine in base dockerfile
This allows us to upgrade rustc to past 1.65, which is required by sea-orm.
2023-01-04 08:24:40 +01:00
Austin Alvarado 1e5603dce2 docker: Add VSCode devcontainer 2023-01-03 18:11:59 +01:00
dependabot[bot] c64d32e2c0 build(deps): bump actions/checkout from 3.1.0 to 3.2.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.1.0 to 3.2.0.
- [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/v3.1.0...v3.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-12 21:23:30 +01:00
Valentin Tolmer 665e525f0a server: fix user password setting
It used to try to set all user IDs to the same, which would fail if
there is more than 1 user.
2022-11-30 08:38:21 +01:00
Valentin Tolmer 09a0522e2d server: move domain types to a separate file 2022-11-25 15:35:48 +01:00
Valentin Tolmer e89b1538af server,app: migrate to sea-orm 2022-11-25 15:35:48 +01:00
Waldemar Heinze a3a27f0049 docker: update Rust to v1.65.0 2022-11-25 10:07:50 +01:00
Waldemar Heinze a4408cfacc server: prefer immutable path 2022-11-25 00:14:29 +01:00
Waldemar Heinze a3216a4550 server: fix clippy's suggestions 2022-11-24 23:52:57 +01:00
Waldemar Heinze 2668ea4553 server: make host configurable to enable IPv6 support
This change also separates the API host and the LDAP host for further customization.
2022-11-24 23:39:11 +01:00
Michał Mrozek dd7e392626 server: use async api for email sending
Fixes #378
2022-11-24 14:47:56 +01:00
MrOnak 80fc94c4db example_configs: Add Kanboard 2022-11-22 12:13:27 +01:00
Norm ffc59af345 example_configs: Update Nextcloud and add tutorial for OIDC with Authelia 2022-11-18 14:05:16 +01:00
dependabot[bot] 03ad10dfc5 build(deps): bump Swatinem/rust-cache from 1 to 2
Bumps [Swatinem/rust-cache](https://github.com/Swatinem/rust-cache) from 1 to 2.
- [Release notes](https://github.com/Swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Swatinem/rust-cache/compare/v1...v2)

---
updated-dependencies:
- dependency-name: Swatinem/rust-cache
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-18 11:05:43 +01:00
Hobbabobba eb26019a52 example_configs: Added zendto
* Create zendto.md

* Update README.md
2022-11-10 11:06:29 +01:00
Hobbabobba 69d0308f46 example_configs: Add vaultwarden sync 2022-11-04 15:34:14 +01:00
Lewis Larsen ba0dc33583 app: front end improvements
Added colour to required asterisks
    Added padding to the footer
    Added bootstrap class to select elements
    Added various icons to buttons
    Fixed various button layouts
    Reworded some messages
    Moved around some form elements

 Fixes #12
2022-11-03 15:40:02 +01:00
Dedy Martadinata S e0c0efcb2f readme: use nodejs 16 for the build instructions
As there are many distributions, and different nodejs out there, define nodejs version to match the CI build.
2022-11-02 08:19:31 +01:00
Igor Rzegocki e3b1810229 docker: add tzdata package, to support TZ env variable 2022-11-01 12:57:49 +01:00
Valentin Tolmer e81c87f288 server,app: Add support for resetting your password with email
Instead of just username

Fixes #267
2022-10-29 15:04:45 +02:00
Valentin Tolmer 234cb70b97 server: fix handling of present filters
If the filter name was not in the list of attributes to return, it
wouldn't be counted as a valid attribute, meaning that the aliases of
attributes were not recognized.

Fixes #351
2022-10-26 09:29:02 +02:00
Indrek Haav 201e3a93eb Ensure generated JWT doesn't include quotation mark 2022-10-20 13:42:19 +02:00
Valentin Tolmer 27144ee37e server: Add support for creating a user through LDAP 2022-10-20 10:09:17 +02:00
Valentin Tolmer 2477439ecc server: Improve rootDSE
Matches the case-insensitive "objectclass" filter, fix the reported
version, and declares the name context and some other attributes.

Potential fix to #330.
2022-10-19 17:35:45 +02:00
Valentin Tolmer ff66e918cf server: increase max payload size to 16MB
Fixes #337
2022-10-19 17:28:25 +02:00
Valentin Tolmer ee7dc39afa example_configs: Add quotes to authelia filters 2022-10-19 14:13:55 +02:00
Valentin Tolmer 4c69f917e7 server: Improve equality handling in filters
Now the columns are checked and mapped to user columns, to avoid any
ambiguity.

Fixes #341.
2022-10-19 08:43:38 +02:00
Valentin Tolmer 8d19678e39 server: refactor sql backend handler
And add some missing tests
2022-10-18 13:04:59 +02:00
Valentin Tolmer bf42517077 nextest: add configuration 2022-10-17 14:39:44 +02:00
Valentin Tolmer 35aa656677 server: refactor ldap_handler
Split it into several files, move them into the domain folder, introduce
`LdapError` for better control flow.
2022-10-17 14:39:44 +02:00
Valentin Tolmer 0be440efc8 server: Start versioning the DB schema
In preparation for #67.
2022-10-17 09:38:37 +02:00
Roman eefe65c042 example_configs: Add Dell iDrac 2022-10-16 16:10:47 +02:00
Roman a42a532929 example_configs: Add WeKan sample config 2022-10-15 14:42:16 +02:00
Hobbabobba 3bb07db63f example_configs: fix docuwiki group filter 2022-10-12 22:22:48 +02:00
Valentin Tolmer 32850d4ff9 ldap: add entryUUID to the default fields
It should help with #293.
2022-10-12 18:35:40 +02:00
Dedy Martadinata 92178d2e77 github: automate release artifact creation 2022-10-12 17:49:41 +02:00
Valentin Tolmer d592b10c87 docker: install gosu 2022-10-12 16:44:52 +02:00
Valentin Tolmer 188a92d124 docker: add healthcheck 2022-10-12 16:44:52 +02:00
Valentin Tolmer 3aaf53442b server: implement healthcheck 2022-10-12 16:44:52 +02:00
Valentin Tolmer 01d4b6e1fc lock: update Cargo.lock 2022-10-12 11:14:42 +02:00
Floris a2dfca0e37 readme: Update to reflect new stable user env settings 2022-10-11 09:31:26 +02:00
Valentin Tolmer b3f64c6efe Bump version to 0.4.2-alpha 2022-10-10 21:09:34 +02:00
332 changed files with 34815 additions and 11321 deletions
+2
View File
@@ -0,0 +1,2 @@
[profile.default]
fail-fast = false
+26
View File
@@ -0,0 +1,26 @@
FROM rust:1.85
ARG USERNAME=lldapdev
# We need to keep the user as 1001 to match the GitHub runner's UID.
# See https://github.com/actions/checkout/issues/956.
ARG USER_UID=1001
ARG USER_GID=$USER_UID
# Create the user
RUN groupadd --gid $USER_GID $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
&& apt-get update \
&& apt-get install -y sudo \
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME
RUN apt update && \
apt install -y --no-install-recommends libssl-dev musl-dev make perl curl gzip && \
rm -rf /var/lib/apt/lists/*
RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
&& rustup target add wasm32-unknown-unknown
USER $USERNAME
ENV CARGO_HOME=/home/$USERNAME/.cargo
ENV SHELL=/bin/bash
+8
View File
@@ -0,0 +1,8 @@
{
"name": "LLDAP dev",
"build": { "dockerfile": "Dockerfile" },
"forwardPorts": [
3890,
17170
]
}
+15 -1
View File
@@ -2,6 +2,7 @@
.git/*
.github/*
.gitignore
.gitattributes
# Don't track cargo generated files
target/*
@@ -17,6 +18,7 @@ Dockerfile
*.md
LICENSE
CHANGELOG.md
README.md
docs/*
example_configs/*
@@ -28,12 +30,24 @@ package.json
# Pre-build binaries
*.tar.gz
# VSCode dirs
.vscode
.devcontainer
# Created databases
*.db
*.db-shm
*.db-wal
# These are backup files generated by rustfmt
**/*.rs.bk
# Various config files that shouldn't be tracked
.env
lldap_config.toml
server_key
users.db*
screenshot.png
recipe.json
lldap_config.toml
cert.pem
key.pem
+10
View File
@@ -0,0 +1,10 @@
example_configs/** linguist-documentation
docs/** linguist-documentation
*.md linguist-documentation
lldap_config.docker_template.toml linguist-documentation
schema.graphql linguist-generated
.github/** -linguist-detectable
.devcontainer/** -linguist-detectable
.config/** -linguist-detectable
+1
View File
@@ -0,0 +1 @@
* @nitnelave
+5
View File
@@ -0,0 +1,5 @@
# These are supported funding model platforms
github: [lldap]
custom: ['https://bmc.link/nitnelave']
+29
View File
@@ -0,0 +1,29 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Logs**
If applicable, add logs to explain the problem.
LLDAP should be started in verbose mode (`LLDAP_VERBOSE=true` env variable, or `verbose = true` in the config). Include the logs in triple-backtick "```"
If integrating with another service, please add its configuration (paste it or screenshot it) as well as any useful logs or screenshots (showing the error, for instance).
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE REQUEST]"
labels: enhancement
assignees: ''
---
**Motivation**
Why do you want the feature? What problem do you have, what use cases would it enable?
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered. You can include workarounds that are currently possible.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,25 @@
---
name: Integration request
about: Request for integration with a service
title: "[INTEGRATION]"
labels: integration
assignees: ''
---
**Checklist**
- [ ] Check if there is already an [example config](https://github.com/lldap/lldap/tree/main/example_configs) for it.
- [ ] Try to figure out the configuration values for the new service yourself.
- You can use other example configs for inspiration.
- If you're having trouble, you can ask on [Discord](https://discord.gg/h5PEdRMNyP) or create an issue.
- If you succeed, make sure to contribute an example configuration, or a configuration guide.
- If you hit a block because of an unimplemented feature, create an issue.
**Description of the service**
Quick summary of what the service is and how it's using LDAP. Link to the service's documentation on configuring LDAP.
**What you've tried**
A sample configuration that you've tried.
**What's not working**
Error logs, error screenshots, features that are not working, missing features.
+12 -1
View File
@@ -1,12 +1,23 @@
codecov:
require_ci_to_pass: yes
comment:
layout: "diff,flags"
layout: "header,diff,files"
require_changes: true
require_base: true
require_head: true
coverage:
status:
project:
default:
target: "75%"
threshold: "0.1%"
removed_code_behavior: adjust_base
github_checks:
annotations: true
ignore:
- "app"
- "docs"
- "example_configs"
- "migration-tool"
- "scripts"
- "set-password"
+17 -82
View File
@@ -1,65 +1,6 @@
FROM debian:bullseye AS lldap
ARG DEBIAN_FRONTEND=noninteractive
ARG TARGETPLATFORM
RUN apt update && apt install -y wget
WORKDIR /dim
COPY bin/ bin/
COPY web/ web/
RUN mkdir -p target/
RUN mkdir -p /lldap/app
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
mv bin/amd64-bin/lldap target/lldap && \
mv bin/amd64-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-bin/lldap target/lldap && \
mv bin/aarch64-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armhf-bin/lldap target/lldap && \
mv bin/armhf-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
# Web and App dir
COPY docker-entrypoint.sh /docker-entrypoint.sh
COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \
cp target/migration-tool /lldap/ && \
cp -R web/index.html \
web/pkg \
web/static \
/lldap/app/
WORKDIR /lldap
RUN set -x \
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R .
FROM alpine:3.16
WORKDIR /app
ENV UID=1000
ENV GID=1000
ENV USER=lldap
ENV GOSU_VERSION 1.14
# Fetch gosu from git
FROM localhost:5000/lldap/lldap:alpine-base
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
ENV GOSU_VERSION=1.17
RUN set -eux; \
\
apk add --no-cache --virtual .gosu-deps \
@@ -74,9 +15,20 @@ RUN set -eux; \
\
# verify the signature
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
for server in \
hkps://keys.openpgp.org \
ha.pool.sks-keyservers.net \
hkp://p80.pool.sks-keyservers.net:80 \
keyserver.ubuntu.com \
hkp://keyserver.ubuntu.com:80 \
pgp.mit.edu \
; do \
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
break; \
fi; \
done; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
command -v gpgconf && gpgconf --kill all || :; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
\
# clean up fetch dependencies
@@ -86,21 +38,4 @@ RUN set -eux; \
# verify that the binary works
gosu --version; \
gosu nobody true
RUN apk add --no-cache tini ca-certificates bash && \
addgroup -g $GID $USER && \
adduser \
--disabled-password \
--gecos "" \
--home "$(pwd)" \
--ingroup "$USER" \
--no-create-home \
--uid "$UID" \
"$USER" && \
mkdir -p /data && \
chown $USER:$USER /data
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
VOLUME ["/data"]
WORKDIR /app
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
@@ -0,0 +1,85 @@
FROM debian:bullseye AS lldap
ARG DEBIAN_FRONTEND=noninteractive
ARG TARGETPLATFORM
RUN apt update && apt install -y wget
WORKDIR /dim
COPY bin/ bin/
COPY web/ web/
RUN mkdir -p target/
RUN mkdir -p /lldap/app
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/lldap_migration_tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/lldap_migration_tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/lldap_migration_tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
# Web and App dir
COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \
cp target/lldap_migration_tool /lldap/ && \
cp target/lldap_set_password /lldap/ && \
cp -R web/index.html \
web/pkg \
web/static \
/lldap/app/
WORKDIR /lldap
RUN set -x \
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R .
FROM alpine:3.19
WORKDIR /app
ENV UID=1000
ENV GID=1000
ENV USER=lldap
RUN apk add --no-cache tini ca-certificates bash tzdata jq curl jo && \
addgroup -g $GID $USER && \
adduser \
--disabled-password \
--gecos "" \
--home "$(pwd)" \
--ingroup "$USER" \
--no-create-home \
--uid "$UID" \
"$USER" && \
mkdir -p /data && \
chown $USER:$USER /data
COPY --from=lldap --chown=$USER:$USER /lldap /app
VOLUME ["/data"]
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
WORKDIR /app
COPY scripts/bootstrap.sh ./
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]
@@ -0,0 +1,3 @@
FROM localhost:5000/lldap/lldap:alpine-base
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
USER $USER
+45 -71
View File
@@ -1,71 +1,45 @@
FROM debian:bullseye AS lldap
ARG DEBIAN_FRONTEND=noninteractive
ARG TARGETPLATFORM
RUN apt update && apt install -y wget
WORKDIR /dim
COPY bin/ bin/
COPY web/ web/
RUN mkdir -p target/
RUN mkdir -p /lldap/app
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
mv bin/amd64-bin/lldap target/lldap && \
mv bin/amd64-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-bin/lldap target/lldap && \
mv bin/aarch64-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armhf-bin/lldap target/lldap && \
mv bin/armhf-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
# Web and App dir
COPY docker-entrypoint.sh /docker-entrypoint.sh
COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \
cp target/migration-tool /lldap/ && \
cp -R web/index.html \
web/pkg \
web/static \
/lldap/app/
WORKDIR /lldap
RUN set -x \
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R .
FROM debian:bullseye-slim
ENV UID=1000
ENV GID=1000
ENV USER=lldap
RUN apt update && \
apt install -y --no-install-recommends tini openssl ca-certificates gosu && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
mkdir -p /data && chown $USER:$USER /data
COPY --from=lldap --chown=$USER:$USER /lldap /app
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
VOLUME ["/data"]
WORKDIR /app
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]
FROM localhost:5000/lldap/lldap:debian-base
# Taken directly from https://github.com/tianon/gosu/blob/master/INSTALL.md
ENV GOSU_VERSION=1.17
RUN set -eux; \
# save list of currently installed packages for later so we can clean up
savedAptMark="$(apt-mark showmanual)"; \
for i in 1 2 3; do \
apt-get update && \
apt-get install -y --no-install-recommends wget ca-certificates gnupg && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && break || sleep 5; \
done; \
\
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
\
# verify the signature
export GNUPGHOME="$(mktemp -d)"; \
for server in \
hkps://keys.openpgp.org \
ha.pool.sks-keyservers.net \
hkp://p80.pool.sks-keyservers.net:80 \
keyserver.ubuntu.com \
hkp://keyserver.ubuntu.com:80 \
pgp.mit.edu \
; do \
if gpg --batch --keyserver "$server" --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; then \
break; \
fi; \
done; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
\
# clean up fetch dependencies
apt-mark auto '.*' > /dev/null; \
[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; \
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
\
chmod +x /usr/local/bin/gosu; \
# verify that the binary works
gosu --version; \
gosu nobody true
COPY --chown=$USER:$USER docker-entrypoint.sh /docker-entrypoint.sh
@@ -0,0 +1,80 @@
FROM debian:bullseye AS lldap
ARG DEBIAN_FRONTEND=noninteractive
ARG TARGETPLATFORM
RUN apt update && apt install -y wget
WORKDIR /dim
COPY bin/ bin/
COPY web/ web/
RUN mkdir -p target/
RUN mkdir -p /lldap/app
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/x86_64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/lldap_migration_tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/aarch64-unknown-linux-musl-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/lldap_migration_tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armv7-unknown-linux-musleabihf-lldap-bin/lldap target/lldap && \
mv bin/armv7-unknown-linux-musleabihf-lldap_migration_tool-bin/lldap_migration_tool target/lldap_migration_tool && \
mv bin/armv7-unknown-linux-musleabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/lldap_migration_tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
# Web and App dir
COPY docker-entrypoint.sh /docker-entrypoint.sh
COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \
cp target/lldap_migration_tool /lldap/ && \
cp target/lldap_set_password /lldap/ && \
cp -R web/index.html \
web/pkg \
web/static \
/lldap/app/
WORKDIR /lldap
RUN set -x \
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R .
FROM debian:bullseye-slim
ENV UID=1000
ENV GID=1000
ENV USER=lldap
RUN apt update && \
apt install -y --no-install-recommends tini openssl ca-certificates tzdata jq curl jo && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
mkdir -p /data && chown $USER:$USER /data
COPY --from=lldap --chown=$USER:$USER /lldap /app
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
VOLUME ["/data"]
WORKDIR /app
COPY scripts/bootstrap.sh ./
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
@@ -0,0 +1,3 @@
FROM localhost:5000/lldap/lldap:debian-base
COPY --chown=$USER:$USER docker-entrypoint-rootless.sh /docker-entrypoint.sh
USER $USER
+29 -22
View File
@@ -1,34 +1,41 @@
FROM rust:1.62-slim-bullseye
# Keep tracking base image
FROM rust:1.85-slim-bookworm
# Set needed env path
ENV PATH="/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
ENV PATH="/opt/armv7l-linux-musleabihf-cross/:/opt/armv7l-linux-musleabihf-cross/bin/:/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
### Install build deps x86_64
# Set building env
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
CARGO_NET_GIT_FETCH_WITH_CLI=true \
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=armv7l-linux-musleabihf-gcc \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc \
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
CC_armv7_unknown_linux_musleabihf=armv7l-linux-musleabihf-gcc \
CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc \
CC_aarch64_unknown_linux_musl=aarch64-linux-musl-gcc
### Install Additional Build Tools
RUN apt update && \
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools && \
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt update && \
apt install -y --no-install-recommends nodejs && \
apt install -y --no-install-recommends curl git wget make perl pkg-config tar jq gzip && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
npm install -g npm && \
npm install -g yarn && \
npm install -g pnpm
### Install build deps aarch64 build
RUN dpkg --add-architecture arm64 && \
apt update && \
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
rustup target add aarch64-unknown-linux-gnu
### Add musl-gcc aarch64 and x86_64
rm -rf /var/lib/apt/lists/*
### Add musl-gcc aarch64, x86_64 and armv7l
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
tar zxf ./x86_64-linux-musl-cross.tgz -C /opt && \
wget -c https://musl.cc/aarch64-linux-musl-cross.tgz && \
tar zxf ./aarch64-linux-musl-cross.tgz -C /opt && \
wget -c http://musl.cc/armv7l-linux-musleabihf-cross.tgz && \
tar zxf ./armv7l-linux-musleabihf-cross.tgz -C /opt && \
rm ./x86_64-linux-musl-cross.tgz && \
rm ./aarch64-linux-musl-cross.tgz
rm ./aarch64-linux-musl-cross.tgz && \
rm ./armv7l-linux-musleabihf-cross.tgz
### Add musl target
RUN rustup target add x86_64-unknown-linux-musl && \
rustup target add aarch64-unknown-linux-musl && \
rustup target add armv7-unknown-linux-musleabihf && \
rustup target add x86_64-unknown-freebsd
CMD ["bash"]
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -13,7 +13,6 @@ jobs:
pre_job:
continue-on-error: true
runs-on: ubuntu-latest
# Map a step output to a job output
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }}
steps:
@@ -22,7 +21,7 @@ jobs:
with:
concurrent_skipping: 'outdated_runs'
skip_after_successful_duplicate: 'true'
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh"]'
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".dockerignore", ".gitignore", "lldap_config.docker_template.toml", "Dockerfile"]'
do_not_skip: '["workflow_dispatch", "schedule"]'
cancel_others: true
@@ -34,8 +33,8 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v3.1.0
- uses: Swatinem/rust-cache@v1
uses: actions/checkout@v5.0.0
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --verbose --workspace
- name: Run tests
@@ -53,9 +52,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3.1.0
uses: actions/checkout@v5.0.0
- uses: Swatinem/rust-cache@v1
- uses: Swatinem/rust-cache@v2
- name: Run cargo clippy
uses: actions-rs/cargo@v1
@@ -70,9 +69,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3.1.0
uses: actions/checkout@v5.0.0
- uses: Swatinem/rust-cache@v1
- uses: Swatinem/rust-cache@v2
- name: Run cargo fmt
uses: actions-rs/cargo@v1
@@ -82,26 +81,30 @@ jobs:
coverage:
name: Code coverage
needs: pre_job
needs:
- pre_job
- test
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3.1.0
uses: actions/checkout@v5.0.0
- name: Install Rust
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
- uses: taiki-e/install-action@cargo-llvm-cov
- uses: Swatinem/rust-cache@v1
- uses: Swatinem/rust-cache@v2
- name: Generate code coverage for unit test
run: cargo llvm-cov --workspace --no-report
- name: Aggregate reports
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
- name: Upload coverage to Codecov (main)
uses: codecov/codecov-action@v4
with:
files: lcov.info
fail_ci_if_error: true
codecov_yml_path: .github/codecov.yml
token: ${{ secrets.CODECOV_TOKEN }}
+1
View File
@@ -23,6 +23,7 @@ server_key
*.tar.gz
# Misc
.vscode
.env
recipe.json
lldap_config.toml
+308 -1
View File
@@ -5,7 +5,314 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.6.2] 2025-07-21
Small release, focused on LDAP improvements and ongoing maintenance.
### Added
- LDAP
- Support for searching groups by their `groupid`
- Support for `whoamiOID`
- Support for creating groups
- Support for subschema entry
- Custom assets path.
- New endpoint for requesting client settings
### Changed
- A missing JWT secret now prevents startup.
- Attributes with invalid characters (such as underscores) cannot be created anymore.
- Searching custom (string) attributes is now case insensitive.
- Using the top-level `firstName`, `lastName` and `avatar` GraphQL fields for users is now deprecated. Use the `attributes` field instead.
### Fixed
- `lldap_set_password` now uses the system's SSL certificates.
### Cleanups
- Split the main `lldap` crate into many sub-crates
- Various dependency version bumps
- Upgraded to 2024 Rust edition
- Docs/FAQ improvements
### Bootstrap script
- Custom attributes support
- Read the paswsord from a file
- Resilient to no user or group files
### New services
- Discord integration (Discord role to LLDAP user)
- HashiCorp
- Jellyfin 2FA with Duo
- Kimai
- Mailcow
- Peertube
- Penpot
- PgAdmin
- Project Quay
- Quadlet
- Snipe-IT
- SSSD
- Stalwart
- UnifiOS
## [0.6.1] 2024-11-22
Small release, mainly to fix a migration issue with Sqlite and Postgresql.
### Added
- Added a link to a community terraform provider (#1035)
### Changed
- The opaque dependency now points to the official crate rather than a fork (#1040)
### Fixed
- Migration of the DB schema from 7 to 8 is now automatic for sqlite, and fixed for postgres (#1045)
- The startup warning about `key_seed` applying instead of `key_file` now has instructions on how to silence it (#1032)
### New services
- OneDev
## [0.6.0] 2024-11-09
### Breaking
- The endpoint `/auth/reset/step1` is now `POST` instead of `GET` (#704)
### Added
- Custom attributes are now supported (#67) ! You can add new fields (string, integers, JPEG or dates) to users and query them. That unlocks many integrations with other services, and allows for a deeper/more customized integration. Special thanks to @pixelrazor and @bojidar-bg for their help with the UI.
- Custom object classes (for all users/groups) can now be added (#833)
- Barebones support for Paged Results Control (no paging, no respect for windows, but a correct response with all the results) (#698)
- A daily docker image is tagged and released. (#613)
- A bootstrap script allows reading the list of users/groups from a file and making sure the server contains exactly the same thing. (#654)
- Make it possible to serve lldap behind a sub-path in (#752)
- LLDAP can now be found on a custom package repository for opensuse, fedora, ubuntu, debian and centos ([Repository link](https://software.opensuse.org//download.html?project=home%3AMasgalor%3ALLDAP&package=lldap)). Thanks @Masgalor for setting it up and maintaining it.
- There's now an option to force reset the admin password (#748) optionally on every restart (#959)
- There's a rootless docker container (#755)
- entryDN is now supported (#780)
- Unknown LDAP controls are now detected and ignored (#787, #799)
- A community-developed CLI for scripting (#793)
- Added a way to print raw logs to debug long-running sessions (#992)
### Changed
- The official docker repository is now `lldap/lldap`
- Removed password length limitation in lldap_set_password tool
- Group names and emails are now case insensitive, but keep their casing (#666)
- Better error messages (and exit code (#745)) when changing the private key (#778, #1008), using the wrong SMTP port (#970), using the wrong env variables (#972)
- Allow `member=` filters with plain user names (not full DNs) (#949)
- Correctly detect and refuse anonymous binds (#974)
- Clearer logging (#971, #981, #982)
### Fixed
- Logging out applies globally, not just in the local browser. (#721)
- It's no longer possible to create the same user twice (#745)
- Fix wide substring filters (#738)
- Don't log the database password if provided in the connection URL (#735)
- Fix a panic when postgres uses a different collation (#821)
- The UI now defaults to the user ID for users with no display names (#843)
- Fix searching for users with more than one `memberOf` filter (#872)
- Fix compilation on Windows (#932) and Illumos (#964)
- The UI now correctly detects whether password resets are enabled. (#753)
- Fix a missing lowercasing of username when changing passwords through LDAP (#1012)
- Fix SQLite writers erroring when racing (#1021)
- LDAP sessions no longer buffer their logs until unbind, causing memory leaks (#1025)
### Performance
- Only expand attributes once per query, not per result (#687)
### Security
- When asked to send a password reset to an unknown email, sleep for 3 seconds and don't print the email in the error (#887)
### New services
Linux user accounts can now be managed by LLDAP, using PAM and nslcd.
- Apereo CAS server
- Carpal
- Gitlab
- Grocy
- Harbor
- Home Assistant
- Jenkins
- Kasm
- Maddy
- Mastodon
- Metabase
- MegaRAC-BMC
- Netbox
- OCIS
- Prosody
- Radicale
- SonarQube
- Traccar
- Zitadel
## [0.5.0] 2023-09-14
### Breaking
- Emails and UUIDs are now enforced to be unique.
- If you have several users with the same email, you'll have to disambiguate
them. You can do that by either issuing SQL commands directly
(`UPDATE users SET email = 'x@x' WHERE user_id = 'bob';`), or by reverting
to a 0.4.x version of LLDAP and editing the user through the web UI.
An error will prevent LLDAP 0.5+ from starting otherwise.
- This was done to prevent account takeover for systems that allow to
login via email.
### Added
- The server private key can be set as a seed from an env variable (#504).
- This is especially useful when you have multiple containers, they don't
need to share a writeable folder.
- Added support for changing the password through a plain LDAP Modify
operation (as opposed to an extended operation), to allow Jellyfin
to change password (#620).
- Allow creating a user with multiple objectClass (#612).
- Emails now have a message ID (#608).
- Added a warning for browsers that have WASM/JS disabled (#639).
- Added support for querying OUs in LDAP (#669).
- Added a button to clear the avatar in the UI (#358).
### Changed
- Groups are now sorted by name in the web UI (#623).
- ARM build now uses musl (#584).
- Improved logging.
- Default admin user is only created if there are no admins (#563).
- That allows you to remove the default admin, making it harder to
bruteforce.
### Fixed
- Fixed URL parsing with a trailing slash in the password setting utility
(#597).
In addition to all that, there was significant progress towards #67,
user-defined attributes. That complex feature will unblock integration with many
systems, including PAM authentication.
### New services
- Ejabberd
- Ergo
- LibreNMS
- Mealie
- MinIO
- OpnSense
- PfSense
- PowerDnsAdmin
- Proxmox
- Squid
- Tandoor recipes
- TheLounge
- Zabbix-web
- Zulip
## [0.4.3] 2023-04-11
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
and on DockerHub (although we will keep publishing the images to
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
migrated, and the new docker images are available both on DockerHub and on the
GHCR under `lldap/lldap`.
### Added
- EC private keys are not supported for LDAPS.
### Changed
- SMTP user no longer has a default value (and instead defaults to unauthenticated).
### Fixed
- WASM payload is now delivered uncompressed to Safari due to a Safari bug.
- Password reset no longer redirects to login page.
- NextCloud config should add the "mail" attribute.
- GraphQL parameters are now urldecoded, to support special characters in usernames.
- Healthcheck correctly checks the server certificate.
### New services
- Home Assistant
- Shaarli
## [0.4.2] - 2023-03-27
### Added
- Add support for MySQL/MariaDB/PostgreSQL, in addition to SQLite.
- Healthcheck command for docker setups.
- User creation through LDAP.
- IPv6 support.
- Dev container for VsCode.
- Add support for DN LDAP filters.
- Add support for SubString LDAP filters.
- Add support for LdapCompare operation.
- Add support for unencrypted/unauthenticated SMTP connection.
- Add a command to setup the database schema.
- Add a tool to set a user's password from the command line.
- Added consistent release artifacts.
### Changed
- Payload is now compressed, reducing the size to 700kb.
- entryUUID is returned in the default LDAP fields.
- Slightly improved support for LDAP browsing tools.
- Password reset can be identified by email (instead of just username).
- Various front-end improvements, and support for dark mode.
- Add content-type header to the password reset email, fixing rendering issues in some clients.
- Identify groups with "cn" instead of "uid" in memberOf field.
### Removed
- Removed dependency on nodejs/rollup.
### Fixed
- Email is now using the async API.
- Fix handling of empty/null names (display, first, last).
- Obscured old password field when changing password.
- Respect user setting to disable password resets.
- Fix handling of "present" filters with unknown attributes.
- Fix handling of filters that could lead to an ambiguous SQL query.
### New services
- Authentik
- Dell iDRAC
- Dex
- Kanboard
- NextCloud + OIDC or Authelia
- Nexus
- SUSE Rancher
- VaultWarden
- WeKan
- WikiJS
- ZendTo
### Dependencies (highlights)
- Upgraded Yew to 0.19
- Upgraded actix to 0.13
- Upgraded clap to 4
- Switched from sea-query to sea-orm 0.11
## [0.4.1] - 2022-10-10
+97
View File
@@ -0,0 +1,97 @@
# How to contribute to LLDAP
## Did you find a bug?
- Make sure there isn't already an [issue](https://github.com/lldap/lldap/issues?q=is%3Aissue+is%3Aopen) for it.
- Check if the bug still happens with the `latest` docker image, or the `main` branch if you compile it yourself.
- [Create an issue](https://github.com/lldap/lldap/issues/new) on GitHub. What makes a great issue:
- A quick summary of the bug.
- Steps to reproduce.
- LLDAP _verbose_ logs when reproducing the bug. Verbose mode can be set through environment variables (`LLDAP_VERBOSE=true`) or in the config (`verbose = true`).
- What you expected to happen.
- What actually happened.
- Other notes (what you tried, why you think it's happening, ...).
## Are you requesting integration with a new service?
- Check if there is already an [example config](https://github.com/lldap/lldap/tree/main/example_configs) for it.
- Try to figure out the configuration values for the new service yourself.
- You can use other example configs for inspiration.
- If you're having trouble, you can ask on [Discord](https://discord.gg/h5PEdRMNyP)
- If you succeed, make sure to contribute an example configuration, or a configuration guide.
- If you hit a block because of an unimplemented feature, go to the next section.
## Are you asking for a new feature?
- Make sure there isn't already an [issue](https://github.com/lldap/lldap/issues?q=is%3Aissue+is%3Aopen) for it.
- [Create an issue](https://github.com/lldap/lldap/issues/new) on GitHub. What makes a great feature request:
- A quick summary of the feature.
- Motivation: what problem does the feature solve?
- Workarounds: what are the currently possible solutions to the problem, however bad?
## Do you want to work on a PR?
That's great! There are 2 main ways to contribute to the project: documentation and code.
### Documentation
The simplest way to contribute is to submit a configuration guide for a new
service: it can be an example configuration file, or a markdown guide
explaining the steps necessary to configure the service.
We also have some
[documentation](https://github.com/lldap/lldap/tree/main/docs) with more
advanced guides (scripting, migrations, ...) you can contribute to.
### Code
If you don't know what to start with, check out the
[good first issues](https://github.com/lldap/lldap/labels/good%20first%20issue).
Otherwise, if you want to fix a specific bug or implement a feature, make sure
to start by creating an issue for it (if it doesn't already exist). There, we
can discuss whether it would be likely to be accepted and consider design
issues. That will save you from going down a wrong path, creating an entire PR
before getting told that it doesn't align with the project or the design is
flawed!
Once we agree on what to do in the issue, you can start working on the PR. A good quality PR has:
- A description of the change.
- The format we use for both commit titles and PRs is:
`tag: Do the thing`
The tag can be: server, app, docker, example_configs, ... It's a broad category.
The rest of the title should be an imperative sentence (see for instance [Commit Message
Guidelines](https://gist.github.com/robertpainsi/b632364184e70900af4ab688decf6f53)).
- The PR should refer to the issue it's addressing (e.g. "Fix #123").
- Explain the _why_ of the change.
- But also the _how_.
- Highlight any potential flaw or limitation.
- The code change should be as small as possible while solving the problem.
- Don't try to code-golf to change fewer characters, but keep logically separate changes in
different PRs.
- Add tests if possible.
- The tests should highlight the original issue in case of a bug.
- Ideally, we can apply the tests without the rest of the change and they would fail. With the
change, they pass.
- In some areas, there is no test infrastructure in place (e.g. for frontend changes). In that
case, do some manual testing and include the results (logs for backend changes, screenshot of a
successful service integration, screenshot of the frontend change).
- For backend changes, the tests should cover a significant portion of the new code paths, or
everything if possible. You can also add more tests to cover existing code.
- Of course, make sure all the existing tests pass. This will be checked anyway in the GitHub CI.
### Workflow
We use [GitHub Flow](https://docs.github.com/en/get-started/quickstart/github-flow):
- Fork the repository.
- (Optional) Create a new branch, or just use `main` in your fork.
- Make your change.
- Create a PR.
- Address the comments by adding more commits to your branch (or to `main`).
- The PR gets merged (the commits get squashed to a single one).
- (Optional) You can delete your branch/fork.
## Reminder
We're all volunteers, so be kind to each other! And since we're doing that in our free time, some
things can take a longer than expected.
Generated
+2745 -1661
View File
File diff suppressed because it is too large Load Diff
+29 -9
View File
@@ -1,14 +1,34 @@
[workspace]
members = [
"server",
"auth",
"app",
"migration-tool"
"server",
"app",
"migration-tool",
"set-password",
"crates/*",
]
default-members = ["server"]
resolver = "2"
# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged.
[patch.crates-io.ldap3_proto]
git = 'https://github.com/nitnelave/ldap3_server/'
rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc'
[workspace.package]
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
documentation = "https://github.com/lldap/lldap"
edition = "2024"
homepage = "https://github.com/lldap/lldap"
license = "GPL-3.0-only"
repository = "https://github.com/lldap/lldap"
[profile.release]
lto = true
[profile.release.package.lldap_app]
opt-level = 's'
[patch.crates-io.lber]
git = 'https://github.com/inejge/ldap3/'
[workspace.dependencies.sea-orm]
version = "1.1.8"
default-features = false
[workspace.dependencies.serde]
version = "1"
+40 -8
View File
@@ -1,5 +1,5 @@
# Build image
FROM rust:alpine3.14 AS chef
FROM rust:alpine3.21 AS chef
RUN set -x \
# Add user
@@ -11,7 +11,7 @@ RUN set -x \
--uid 10001 \
app \
# Install required packages
&& apk add npm openssl-dev musl-dev make perl curl
&& apk add openssl-dev musl-dev make perl curl gzip
USER app
WORKDIR /app
@@ -19,7 +19,6 @@ WORKDIR /app
RUN set -x \
# Install build tools
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
&& npm install rollup \
&& rustup target add wasm32-unknown-unknown
# Prepare the dependency list.
@@ -32,27 +31,59 @@ FROM chef AS builder
COPY --from=planner /tmp/recipe.json recipe.json
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
&& cargo chef cook --release -p lldap \
&& cargo chef cook --release -p migration-tool
&& cargo chef cook --release -p lldap_migration_tool \
&& cargo chef cook --release -p lldap_set_password
# Copy the source and build the app and server.
COPY --chown=app:app . .
RUN cargo build --release -p lldap -p migration-tool \
RUN cargo build --release -p lldap -p lldap_migration_tool -p lldap_set_password \
# Build the frontend.
&& ./app/build.sh
# Final image
FROM alpine:3.14
FROM alpine:3.21
ENV GOSU_VERSION=1.14
# Fetch gosu from git
RUN set -eux; \
\
apk add --no-cache --virtual .gosu-deps \
ca-certificates \
dpkg \
gnupg \
; \
\
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
\
# verify the signature
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
command -v gpgconf && gpgconf --kill all || :; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
\
# clean up fetch dependencies
apk del --no-network .gosu-deps; \
\
chmod +x /usr/local/bin/gosu; \
# verify that the binary works
gosu --version; \
gosu nobody true
WORKDIR /app
COPY --from=builder /app/app/index_local.html app/index.html
COPY --from=builder /app/app/static app/static
COPY --from=builder /app/app/pkg app/pkg
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool ./
COPY --from=builder /app/target/release/lldap /app/target/release/lldap_migration_tool /app/target/release/lldap_set_password ./
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
COPY scripts/bootstrap.sh ./
RUN set -x \
&& apk add --no-cache bash \
&& apk add --no-cache bash tzdata \
&& for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done \
&& for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R .
@@ -64,3 +95,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
+5
View File
@@ -0,0 +1,5 @@
build-dev-container:
docker buildx build --tag lldap/rust-dev --file .github/workflows/Dockerfile.dev --push .github/workflows
prepare-release:
./prepare-release.sh
+112 -232
View File
@@ -5,14 +5,15 @@
</p>
<p align="center">
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
<a href="https://github.com/lldap/lldap/actions/workflows/rust.yml?query=branch%3Amain">
<img
src="https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg"
src="https://github.com/lldap/lldap/actions/workflows/rust.yml/badge.svg"
alt="Build"/>
</a>
<a href="https://discord.gg/h5PEdRMNyP">
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
</a>
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
<img
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
@@ -23,25 +24,25 @@
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
alt="Unsafe forbidden"/>
</a>
<a href="https://app.codecov.io/gh/nitnelave/lldap">
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/nitnelave/lldap" />
<a href="https://app.codecov.io/gh/lldap/lldap">
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/lldap/lldap" />
</a>
<br/>
<a href="https://www.buymeacoffee.com/nitnelave" target="_blank">
<img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" >
</a>
</p>
- [About](#About)
- [Installation](#Installation)
- [With Docker](#With-Docker)
- [From source](#From-source)
- [Cross-compilation](#Cross-compilation)
- [Client configuration](#Client-configuration)
- [Compatible services](#compatible-services)
- [General configuration guide](#general-configuration-guide)
- [Sample client configurations](#Sample-client-configurations)
- [Comparisons with other services](#Comparisons-with-other-services)
- [vs OpenLDAP](#vs-openldap)
- [vs FreeIPA](#vs-freeipa)
- [I can't log in!](#i-cant-log-in)
- [Contributions](#Contributions)
- [About](#about)
- [Installation](docs/install.md)
- [Usage](#usage)
- [Recommended architecture](#recommended-architecture)
- [Client configuration](#client-configuration)
- [Known compatible services](#known-compatible-services)
- [General configuration guide](#general-configuration-guide)
- [Incompatible services](#incompatible-services)
- [Frequently Asked Questions](#frequently-asked-questions)
- [Contributions](#contributions)
## About
@@ -51,7 +52,7 @@ many backends, from KeyCloak to Authelia to Nextcloud and
[more](#compatible-services)!
<img
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
src="https://raw.githubusercontent.com/lldap/lldap/master/screenshot.png"
alt="Screenshot of the user list page"
width="50%"
align="right"
@@ -62,10 +63,11 @@ edit their own details or reset their password by email.
The goal is _not_ to provide a full LDAP server; if you're interested in that,
check out OpenLDAP. This server is a user management system that is:
* simple to setup (no messing around with `slapd`),
* simple to manage (friendly web UI),
* low resources,
* opinionated with basic defaults so you don't have to understand the
- simple to setup (no messing around with `slapd`),
- simple to manage (friendly web UI),
- low resources,
- opinionated with basic defaults so you don't have to understand the
subtleties of LDAP.
It mostly targets self-hosting servers, with open-source components like
@@ -76,149 +78,65 @@ For more features (OAuth/OpenID support, reverse proxy, ...) you can install
other components (KeyCloak, Authelia, ...) using this server as the source of
truth for users, via LDAP.
By default, the data is stored in SQLite, but you can swap the backend with
MySQL/MariaDB or PostgreSQL.
## Installation
### With Docker
It's possible to install lldap from OCI images ([docker](docs/install.md#with-docker)/[podman](docs/install.md#with-podman)), from [Kubernetes](docs/install.md#with-kubernetes), or from [a regular distribution package manager](docs/install.md/#from-a-package-repository) (Archlinux, Debian, CentOS, Fedora, OpenSuse, Ubuntu, FreeBSD).
The image is available at `nitnelave/lldap`. You should persist the `/data`
folder, which contains your configuration, the database and the private key
file.
Building [from source](docs/install.md#from-source) and [cross-compiling](docs/install.md#cross-compilation) to a different hardware architecture is also supported.
Configure the server by copying the `lldap_config.docker_template.toml` to
`/data/lldap_config.toml` and updating the configuration values (especially the
`jwt_secret` and `ldap_user_pass`, unless you override them with env variables).
Environment variables should be prefixed with `LLDAP_` to override the
configuration.
## Usage
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use default one. The default admin password is `password`, you can change the password later using the web interface.
The simplest way to use LLDAP is through the web front-end. There you can
create users, set passwords, add them to groups and so on. Users can also
connect to the web UI and change their information, or request a password reset
link (if you configured the SMTP client).
Secrets can also be set through a file. The filename should be specified by the
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_LDAP_USER_PASS_FILE`, and the file
contents are loaded into the respective configuration parameters. Note that
`_FILE` variables take precedence.
You can create and manage custom attributes through the Web UI, or through the
community-contributed CLI frontend (
[Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli)). This is necessary
for some service integrations.
Example for docker compose for `:stable` tag:
* When defined with `user: ##:##` , ensure `/data` directory had permission for the defined user, else `1000:1000` used.
The [bootstrap.sh](scripts/bootstrap.sh) script can enforce a list of
users/groups/attributes from a given file, reflecting it on the server.
```yaml
version: '3'
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).
volumes:
lldap_data:
driver: local
LLDAP is also very scriptable, through its GraphQL API. See the
[Scripting](docs/scripting.md) docs for more info.
services:
lldap:
image: nitnelave/lldap:stable
# Change this to the user:group you want.
user: "33:33"
ports:
# For LDAP
- "3890:3890"
# For the web front-end
- "17170:17170"
volumes:
- "lldap_data:/data"
# Alternatively, you can mount a local folder
# - "./lldap_data:/data"
environment:
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
```
### Recommended architecture
Example for docker compose for `:latest` tag:
* `:latest` tag image contain recent pushed codes or feature test, breaks is expected.
* If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`
If you are using containers, a sample architecture could look like this:
```yaml
version: '3'
volumes:
lldap_data:
driver: local
services:
lldap:
image: nitnelave/lldap:latest
ports:
# For LDAP
- "3890:3890"
# For the web front-end
- "17170:17170"
volumes:
- "lldap_data:/data"
# Alternatively, you can mount a local folder
# - "./lldap_data:/data"
environment:
- UID=####
- GID=####
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
```
Then the service will listen on two ports, one for LDAP and one for the web
front-end.
### From source
To compile the project, you'll need:
* npm, curl: `sudo apt install curl npm`
* Rust/Cargo: [rustup.rs](https://rustup.rs/)
Then you can compile the server (and the migration tool if you want):
```shell
cargo build --release -p lldap -p migration-tool
```
The resulting binaries will be in `./target/release/`. Alternatively, you can
just run `cargo run -- run` to run the server.
To bring up the server, you'll need to compile the frontend. In addition to
cargo, you'll need:
* WASM-pack: `cargo install wasm-pack`
* rollup.js: `npm install rollup`
Then you can build the frontend files with `./app/build.sh` (you'll need to run
this after every front-end change to update the WASM package served).
The default config is in `src/infra/configuration.rs`, but you can override it
by creating an `lldap_config.toml`, setting environment variables or passing
arguments to `cargo run`. Have a look at the docker template:
`lldap_config.docker_template.toml`.
You can also install it as a systemd service, see
[lldap.service](example_configs/lldap.service).
### Cross-compilation
Docker images are provided for AMD64, ARM64 and ARM/V7.
If you want to cross-compile yourself, you can do so by installing
[`cross`](https://github.com/rust-embedded/cross):
```sh
cargo install cross
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
./app/build.sh
```
(Replace `armv7-unknown-linux-musleabihf` with the correct Rust target for your
device.)
You can then get the compiled server binary in
`target/armv7-unknown-linux-musleabihf/release/lldap` and the various needed files
(`index.html`, `main.js`, `pkg` folder) in the `app` folder. Copy them to the
Raspberry Pi (or other target), with the folder structure maintained (`app`
files in an `app` folder next to the binary).
- A reverse proxy (e.g. nginx or Traefik)
- An authentication service (e.g. Authelia, Authentik or KeyCloak) connected to
LLDAP to provide authentication for non-authenticated services, or to provide
SSO with compatible ones.
- The LLDAP service, with the web port exposed to Traefik.
- The LDAP port doesn't need to be exposed, since only the other containers
will access it.
- You can also set up LDAPS if you want to expose the LDAP port to the
internet (not recommended) or for an extra layer of security in the
inter-container communication (though it's very much optional).
- The default LLDAP container starts up as root to fix up some files'
permissions before downgrading the privilege to the given user. However,
you can (should?) use the `*-rootless` version of the images to be able to
start directly as that user, once you got the permissions right. Just don't
forget to change from the `UID/GID` env vars to the `uid` docker-compose
field.
- Any other service that needs to connect to LLDAP for authentication (e.g.
NextCloud) can be added to a shared network with LLDAP. The finest
granularity is a network for each pair of LLDAP-service, but there are often
coarser granularities that make sense (e.g. a network for the \*arr stack and
LLDAP).
## Client configuration
### Compatible services
### Known compatible services
Most services that can use LDAP as an authentication provider should work out
of the box. For new services, it's possible that they require a bit of tweaking
@@ -226,17 +144,25 @@ on LLDAP's side to make things work. In that case, just create an issue with
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
the config).
Some specific clients have been tested to work and come with sample
configuration files, or guides. See the [`example_configs`](example_configs)
folder for example configs for integration with specific services.
Integration with Linux accounts is possible, through PAM and nslcd. See [PAM
configuration guide](example_configs/pam/README.md). Integration with Windows (e.g. Samba) is WIP.
### General configuration guide
To configure the services that will talk to LLDAP, here are the values:
- The LDAP user DN is from the configuration. By default,
`cn=admin,ou=people,dc=example,dc=com`.
- The LDAP password is from the configuration (same as to log in to the web
UI).
- The users are all located in `ou=people,` + the base DN, so by default user
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
- Similarly, the groups are located in `ou=groups`, so the group `family`
will be at `cn=family,ou=groups,dc=example,dc=com`.
- The LDAP user DN is from the configuration. By default,
`cn=admin,ou=people,dc=example,dc=com`.
- The LDAP password is from the configuration (same as to log in to the web
UI).
- The users are all located in `ou=people,` + the base DN, so by default user
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
- Similarly, the groups are located in `ou=groups`, so the group `family`
will be at `cn=family,ou=groups,dc=example,dc=com`.
Testing group membership through `memberOf` is supported, so you can have a
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
@@ -244,87 +170,41 @@ filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
admin rights in the Web UI. Most LDAP integrations should instead use a user in
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
administration access to many services.
administration access to many services. To prevent privilege escalation users in the
`lldap_password_manager` group are not allowed to change passwords of admins in the
`lldap_admin` group.
### Sample client configurations
### Incompatible services
Some specific clients have been tested to work and come with sample
configuration files, or guides. See the [`example_configs`](example_configs)
folder for help with:
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
- [Apache Guacamole](example_configs/apacheguacamole.md)
- [Authelia](example_configs/authelia_config.yml)
- [Bookstack](example_configs/bookstack.env.example)
- [Calibre-Web](example_configs/calibre_web.md)
- [Dokuwiki](example_configs/dokuwiki.md)
- [Dolibarr](example_configs/dolibarr.md)
- [Emby](example_configs/emby.md)
- [Gitea](example_configs/gitea.md)
- [Grafana](example_configs/grafana_ldap_config.toml)
- [Hedgedoc](example_configs/hedgedoc.md)
- [Jellyfin](example_configs/jellyfin.md)
- [Jitsi Meet](example_configs/jitsi_meet.conf)
- [KeyCloak](example_configs/keycloak.md)
- [Matrix](example_configs/matrix_synapse.yml)
- [Nextcloud](example_configs/nextcloud.md)
- [Organizr](example_configs/Organizr.md)
- [Portainer](example_configs/portainer.md)
- [Seafile](example_configs/seafile.md)
- [Syncthing](example_configs/syncthing.md)
- [WG Portal](example_configs/wg_portal.env.example)
- [XBackBone](example_configs/xbackbone_config.php)
Though we try to be maximally compatible, not every feature is supported; LLDAP
is not a fully-featured LDAP server, intentionally so.
## Comparisons with other services
LDAP browsing tools are generally not supported, though they could be. If you
need to use one but it behaves weirdly, please file a bug.
### vs OpenLDAP
Some services use features that are not implemented, or require specific
attributes. You can try to create those attributes (see custom attributes in
the [Usage](#usage) section).
OpenLDAP is a monster of a service that implements all of LDAP and all of its
extensions, plus some of its own. That said, if you need all that flexibility,
it might be what you need! Note that installation can be a bit painful
(figuring out how to use `slapd`) and people have mixed experiences following
tutorials online. If you don't configure it properly, you might end up storing
passwords in clear, so a breach of your server would reveal all the stored
passwords!
Finally, some services require password hashes so they can validate themselves
the user's password without contacting LLDAP. This is not and will not be
supported, it's incompatible with our password hashing scheme (a zero-knowledge
proof). Furthermore, it's generally not recommended in terms of security, since
it duplicates the places from which a password hash could leak.
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
install one (not that many that look nice) and configure it.
In that category, the most prominent is Synology. It is, to date, the only
service that seems definitely incompatible with LLDAP.
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
## Frequently Asked Questions
### vs FreeIPA
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
Samba, you name it, it has it. In addition to user management, it also does
security policies, single sign-on, certificate management, linux account
management and so on.
If you need all of that, go for it! Keep in mind that a more complex system is
more complex to maintain, though.
LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
configure (no messing around with DNS or security policies) and simpler to
use. It also comes conveniently packed in a docker container.
## I can't log in!
If you just set up the server, can get to the login page but the password you
set isn't working, try the following:
- (For docker): Make sure that the `/data` folder is persistent, either to a
docker volume or mounted from the host filesystem.
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
or in the current directory). If there isn't, copy
`lldap_config.docker_template.toml` there, and fill in the various values
(passwords, secrets, ...).
- Check if there is a `users.db` file (either in `/data` for docker or where
you specified the DB URL, which defaults to the current directory). If
there isn't, check that the user running the command (user with ID 10001
for docker) has the rights to write to the `/data` folder. If in doubt, you
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
- Make sure you restart the server.
- If it's still not working, join the
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
- [I can't login](docs/faq.md#i-cant-log-in)
- [Discord Integration](docs/faq.md#discord-integration)
- [Migrating from SQLite](docs/faq.md#migrating-from-sqlite)
- How does lldap compare [with OpenLDAP](docs/faq.md#how-does-lldap-compare-with-openldap)? [With FreeIPA](docs/faq.md#how-does-lldap-compare-with-freeipa)? [With Kanidm](docs/faq.md#how-does-lldap-compare-with-kanidm)?
- [Does lldap support vhosts?](docs/faq.md#does-lldap-support-vhosts)
- [Does lldap provide commercial support contracts?](docs/faq.md#does-lldap-provide-commercial-support-contracts)
- [Can I make a donation to fund development?](docs/faq.md#can-i-make-a-donation-to-fund-development)
- [Is lldap sustainable? Can we depend on it for our infrastructure?](docs/faq.md#is-lldap-sustainable-can-we-depend-on-it-for-our-infrastructure)
## Contributions
+55 -13
View File
@@ -1,24 +1,32 @@
[package]
name = "lldap_app"
version = "0.4.1"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
version = "0.6.2"
description = "Frontend for LLDAP"
edition.workspace = true
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
authors.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
anyhow = "1"
base64 = "0.13"
gloo-console = "0.2.3"
gloo-file = "0.2.3"
gloo-net = "*"
graphql_client = "0.10"
http = "0.2"
jwt = "0.13"
rand = "0.8"
serde = "1"
serde_json = "1"
validator = "=0.14"
validator_derive = "*"
wasm-bindgen = "0.2"
yew = "0.18"
yewtil = "*"
yew-router = "0.15"
url-escape = "0.1.1"
validator = "0.14"
validator_derive = "0.14"
wasm-bindgen = "0.2.100"
wasm-bindgen-futures = "*"
yew = "0.19.3"
yew-router = "0.16"
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
indexmap = "=1.6.2"
@@ -28,12 +36,16 @@ version = "0.3"
features = [
"Document",
"Element",
"Event",
"FileReader",
"FormData",
"HtmlDocument",
"HtmlFormElement",
"HtmlInputElement",
"HtmlOptionElement",
"HtmlOptionsCollection",
"HtmlSelectElement",
"SubmitEvent",
"console",
]
@@ -43,22 +55,52 @@ features = [
"wasmbind"
]
[dependencies.derive_more]
features = ["debug", "display", "from", "from_str"]
default-features = false
version = "1"
[dependencies.lldap_auth]
path = "../auth"
path = "../crates/auth"
features = [ "opaque_client" ]
[dependencies.lldap_frontend_options]
path = "../crates/frontend-options"
[dependencies.lldap_validation]
path = "../crates/validation"
[dependencies.image]
features = ["jpeg"]
default-features = false
version = "0.24"
[dependencies.serde]
workspace = true
[dependencies.strum]
features = ["derive"]
version = "0.25"
[dependencies.yew_form]
git = "https://github.com/jfbilodeau/yew_form"
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
[dependencies.yew_form_derive]
git = "https://github.com/jfbilodeau/yew_form"
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
[lib]
crate-type = ["cdylib"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(wasm_bindgen_unstable_test_coverage)',
] }
[package.metadata.wasm-pack.profile.dev]
wasm-opt = ['--enable-bulk-memory']
[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['--enable-bulk-memory']
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['--enable-bulk-memory']
+5 -15
View File
@@ -6,22 +6,12 @@ then
>&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`'
exit 1
fi
wasm-pack build --target web
ROLLUP_BIN=$(which rollup 2>/dev/null)
if [ -f ../node_modules/rollup/dist/bin/rollup ]
if ! which gzip > /dev/null 2>&1
then
ROLLUP_BIN=../node_modules/rollup/dist/bin/rollup
elif [ -f node_modules/rollup/dist/bin/rollup ]
then
ROLLUP_BIN=node_modules/rollup/dist/bin/rollup
fi
if [ -z "$ROLLUP_BIN" ]
then
>&2 echo '`rollup` not found. Try running `npm install rollup`'
>&2 echo '`gzip` not found.'
exit 1
fi
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap
wasm-pack build --target web --release
gzip -9 -k -f pkg/lldap_app_bg.wasm
+31 -4
View File
@@ -4,17 +4,22 @@
<head>
<meta charset="utf-8" />
<title>LLDAP Administration</title>
<script src="/pkg/bundle.js" defer></script>
<base href="/">
<script src="static/main.js" type="module" defer></script>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
rel="preload stylesheet"
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
crossorigin="anonymous"
as="style" />
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
crossorigin="anonymous"></script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
crossorigin="anonymous"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
@@ -29,11 +34,33 @@
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
<link
rel="stylesheet"
href="/static/style.css" />
href="static/style.css" />
<script>
function inDarkMode(){
return darkmode.inDarkMode;
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<noscript>
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
LLDAP requires JavaScript, please switch to a compatible browser or
enable it.
</noscript>
<script>
/* Detect if the user has WASM support. */
if (typeof WebAssembly === 'undefined') {
const pWASMMsg = document.createElement("p")
pWASMMsg.innerHTML = `
LLDAP requires WASM and JIT for JavaScript, please switch to a
compatible browser or enable it.
`
document.body.appendChild(pWASMMsg)
}
</script>
</body>
</html>
+28 -3
View File
@@ -4,15 +4,18 @@
<head>
<meta charset="utf-8" />
<title>LLDAP Administration</title>
<script src="/pkg/bundle.js" defer></script>
<script src="/static/main.js" type="module" defer></script>
<link
href="/static/bootstrap.min.css"
href="/static/bootstrap-nightshade.min.css"
rel="preload stylesheet"
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
as="style" />
<script
src="/static/bootstrap.bundle.min.js"
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
<script
src="/static/darkmode.min.js"
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
<link
rel="stylesheet"
href="/static/bootstrap-icons.css"
@@ -28,10 +31,32 @@
<link
rel="stylesheet"
href="/static/style.css" />
<script>
function inDarkMode(){
return darkmode.inDarkMode;
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<noscript>
<!-- This will be displayed if the user doesn't have JavaScript enabled. -->
LLDAP requires JavaScript, please switch to a compatible browser or
enable it.
</noscript>
<script>
/* Detect if the user has WASM support. */
if (typeof WebAssembly === 'undefined') {
const pWASMMsg = document.createElement("p")
pWASMMsg.innerHTML = `
LLDAP requires WASM and JIT for JavaScript, please switch to a
compatible browser or enable it.
`
document.body.appendChild(pWASMMsg)
}
</script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
import init, { run_app } from './pkg/lldap_app.js';
async function main() {
await init('/pkg/lldap_app_bg.wasm');
run_app();
}
main()
+2 -2
View File
@@ -1,5 +1,5 @@
mutation CreateGroup($name: String!) {
createGroup(name: $name) {
mutation CreateGroup($group: CreateGroupInput!) {
createGroupWithDetails(request: $group) {
id
displayName
}
@@ -0,0 +1,5 @@
mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!) {
addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: false) {
ok
}
}
@@ -0,0 +1,5 @@
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
ok
}
}
@@ -0,0 +1,5 @@
mutation DeleteGroupAttributeQuery($name: String!) {
deleteGroupAttribute(name: $name) {
ok
}
}
@@ -0,0 +1,5 @@
mutation DeleteUserAttributeQuery($name: String!) {
deleteUserAttribute(name: $name) {
ok
}
}
@@ -0,0 +1,14 @@
query GetGroupAttributesSchema {
schema {
groupSchema {
attributes {
name
attributeType
isList
isVisible
isHardcoded
isReadonly
}
}
}
}
+17
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
}
}
}
}
@@ -0,0 +1,15 @@
query GetUserAttributesSchema {
schema {
userSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}
+18 -3
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
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
mutation UpdateGroup($group: UpdateGroupInput!) {
updateGroup(group: $group) {
ok
}
}
+36 -28
View File
@@ -52,23 +52,25 @@ pub struct Props {
}
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::UserListResponse(response) => {
self.user_list = Some(response?.users);
self.common.cancel_task();
}
Msg::SubmitAddMember => return self.submit_add_member(),
Msg::SubmitAddMember => return self.submit_add_member(ctx),
Msg::AddMemberResponse(response) => {
response?;
self.common.cancel_task();
let user = self
.selected_user
.as_ref()
.expect("Could not get selected user")
.clone();
// Remove the user from the dropdown.
self.common.on_user_added_to_group.emit(user);
ctx.props().on_user_added_to_group.emit(user);
}
Msg::SelectionChanged(option_props) => {
let was_some = self.selected_user.is_some();
@@ -88,23 +90,25 @@ impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
}
impl AddGroupMemberComponent {
fn get_user_list(&mut self) {
fn get_user_list(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<ListUserNames, _>(
ctx,
list_user_names::Variables { filters: None },
Msg::UserListResponse,
"Error trying to fetch user list",
);
}
fn submit_add_member(&mut self) -> Result<bool> {
fn submit_add_member(&mut self, ctx: &Context<Self>) -> Result<bool> {
let user_id = match self.selected_user.clone() {
None => return Ok(false),
Some(user) => user.id,
};
self.common.call_graphql::<AddUserToGroup, _>(
ctx,
add_user_to_group::Variables {
user: user_id,
group: self.common.group_id,
group: ctx.props().group_id,
},
Msg::AddMemberResponse,
"Error trying to initiate adding the user to a group",
@@ -112,8 +116,8 @@ impl AddGroupMemberComponent {
Ok(true)
}
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
fn get_selectable_user_list(&self, ctx: &Context<Self>, user_list: &[User]) -> Vec<User> {
let user_groups = ctx.props().users.iter().collect::<HashSet<_>>();
user_list
.iter()
.filter(|u| !user_groups.contains(u))
@@ -126,41 +130,44 @@ impl Component for AddGroupMemberComponent {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut res = Self {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
user_list: None,
selected_user: None,
};
res.get_user_list();
res.get_user_list(ctx);
res
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error(
self,
ctx,
msg,
self.common.on_error.clone(),
ctx.props().on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
if let Some(user_list) = &self.user_list {
let to_add_user_list = self.get_selectable_user_list(user_list);
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
#[allow(unused_braces)]
let make_select_option = |user: User| {
let name = if user.display_name.is_empty() {
user.id.clone()
} else {
user.display_name.clone()
};
html_nested! {
<SelectOption value=user.id.clone() text=user.display_name.clone() key=user.id />
<SelectOption value={user.id.clone()} text={name} key={user.id} />
}
};
html! {
<div class="row">
<div class="col-sm-3">
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
{
to_add_user_list
.into_iter()
@@ -169,12 +176,13 @@ impl Component for AddGroupMemberComponent {
}
</Select>
</div>
<div class="col-sm-1">
<div class="col-3">
<button
class="btn btn-success"
disabled=self.selected_user.is_none() || self.common.is_task_running()
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
{"Add"}
class="btn btn-secondary"
disabled={self.selected_user.is_none() || self.common.is_task_running()}
onclick={link.callback(|_| Msg::SubmitAddMember)}>
<i class="bi-person-plus me-2"></i>
{"Add to group"}
</button>
</div>
</div>
+31 -28
View File
@@ -64,16 +64,18 @@ pub struct Props {
}
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::GroupListResponse(response) => {
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
self.common.cancel_task();
}
Msg::SubmitAddGroup => return self.submit_add_group(),
Msg::SubmitAddGroup => return self.submit_add_group(ctx),
Msg::AddGroupResponse(response) => {
response?;
self.common.cancel_task();
// Adding the user to the group succeeded, we're not in the process of adding a
// group anymore.
let group = self
@@ -82,7 +84,7 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
.expect("Could not get selected group")
.clone();
// Remove the group from the dropdown.
self.common.on_user_added_to_group.emit(group);
ctx.props().on_user_added_to_group.emit(group);
}
Msg::SelectionChanged(option_props) => {
let was_some = self.selected_group.is_some();
@@ -102,22 +104,24 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
}
impl AddUserToGroupComponent {
fn get_group_list(&mut self) {
fn get_group_list(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<GetGroupList, _>(
ctx,
get_group_list::Variables,
Msg::GroupListResponse,
"Error trying to fetch group list",
);
}
fn submit_add_group(&mut self) -> Result<bool> {
fn submit_add_group(&mut self, ctx: &Context<Self>) -> Result<bool> {
let group_id = match &self.selected_group {
None => return Ok(false),
Some(group) => group.id,
};
self.common.call_graphql::<AddUserToGroup, _>(
ctx,
add_user_to_group::Variables {
user: self.common.username.clone(),
user: ctx.props().username.clone(),
group: group_id,
},
Msg::AddGroupResponse,
@@ -126,8 +130,8 @@ impl AddUserToGroupComponent {
Ok(true)
}
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
fn get_selectable_group_list(&self, props: &Props, group_list: &[Group]) -> Vec<Group> {
let user_groups = props.groups.iter().collect::<HashSet<_>>();
group_list
.iter()
.filter(|g| !user_groups.contains(g))
@@ -139,41 +143,39 @@ impl AddUserToGroupComponent {
impl Component for AddUserToGroupComponent {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut res = Self {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
group_list: None,
selected_group: None,
};
res.get_group_list();
res.get_group_list(ctx);
res
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error(
self,
ctx,
msg,
self.common.on_error.clone(),
ctx.props().on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
if let Some(group_list) = &self.group_list {
let to_add_group_list = self.get_selectable_group_list(group_list);
let to_add_group_list = self.get_selectable_group_list(ctx.props(), group_list);
#[allow(unused_braces)]
let make_select_option = |group: Group| {
html_nested! {
<SelectOption value=group.id.to_string() text=group.display_name key=group.id />
<SelectOption value={group.id.to_string()} text={group.display_name} key={group.id} />
}
};
html! {
<div class="row">
<div class="col-sm-3">
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
{
to_add_group_list
.into_iter()
@@ -182,12 +184,13 @@ impl Component for AddUserToGroupComponent {
}
</Select>
</div>
<div class="col-sm-1">
<div class="col-sm-3">
<button
class="btn btn-success"
disabled=self.selected_group.is_none() || self.common.is_task_running()
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
{"Add"}
class="btn btn-secondary"
disabled={self.selected_group.is_none() || self.common.is_task_running()}
onclick={link.callback(|_| Msg::SubmitAddGroup)}>
<i class="bi-person-plus me-2"></i>
{"Add to group"}
</button>
</div>
</div>
+181 -183
View File
@@ -1,285 +1,283 @@
use crate::{
components::{
banner::Banner,
change_password::ChangePasswordForm,
create_group::CreateGroupForm,
create_group_attribute::CreateGroupAttributeForm,
create_user::CreateUserForm,
create_user_attribute::CreateUserAttributeForm,
group_details::GroupDetails,
group_schema_table::ListGroupSchema,
group_table::GroupTable,
login::LoginForm,
logout::LogoutButton,
reset_password_step1::ResetPasswordStep1Form,
reset_password_step2::ResetPasswordStep2Form,
router::{AppRoute, Link, NavButton},
router::{AppRoute, Link, Redirect},
user_details::UserDetails,
user_schema_table::ListUserSchema,
user_table::UserTable,
},
infra::cookies::get_cookie,
};
use yew::prelude::*;
use yew::services::ConsoleService;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
router::Router,
service::RouteService,
infra::{api::HostService, cookies::get_cookie},
};
use gloo_console::error;
use lldap_frontend_options::Options;
use yew::{
Context, function_component,
html::Scope,
prelude::{Component, Html, html},
};
use yew_router::{
BrowserRouter, Switch,
prelude::{History, Location},
scope_ext::RouterScopeExt,
};
#[function_component(AppContainer)]
pub fn app_container() -> Html {
html! {
<BrowserRouter>
<App />
</BrowserRouter>
}
}
pub struct App {
link: ComponentLink<Self>,
user_info: Option<(String, bool)>,
redirect_to: Option<AppRoute>,
route_dispatcher: RouteAgentDispatcher,
password_reset_enabled: Option<bool>,
}
pub enum Msg {
Login((String, bool)),
Logout,
SettingsReceived(anyhow::Result<Options>),
}
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut app = Self {
link,
fn create(ctx: &Context<Self>) -> Self {
let app = Self {
user_info: get_cookie("user_id")
.unwrap_or_else(|e| {
ConsoleService::error(&e.to_string());
error!(&e.to_string());
None
})
.and_then(|u| {
get_cookie("is_admin")
.map(|so| so.map(|s| (u, s == "true")))
.unwrap_or_else(|e| {
ConsoleService::error(&e.to_string());
error!(&e.to_string());
None
})
}),
redirect_to: Self::get_redirect_route(),
route_dispatcher: RouteAgentDispatcher::new(),
redirect_to: Self::get_redirect_route(ctx),
password_reset_enabled: None,
};
app.apply_initial_redirections();
ctx.link()
.send_future(async move { Msg::SettingsReceived(HostService::get_settings().await) });
app.apply_initial_redirections(ctx);
app
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
let history = ctx.link().history().unwrap();
match msg {
Msg::Login((user_name, is_admin)) => {
self.user_info = Some((user_name.clone(), is_admin));
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(
self.redirect_to.take().unwrap_or_else(|| {
if is_admin {
AppRoute::ListUsers
} else {
AppRoute::UserDetails(user_name.clone())
}
}),
)));
history.push(self.redirect_to.take().unwrap_or_else(|| {
if is_admin {
AppRoute::ListUsers
} else {
AppRoute::UserDetails {
user_id: user_name.clone(),
}
}
}));
}
Msg::Logout => {
self.user_info = None;
self.redirect_to = None;
history.push(AppRoute::Login);
}
Msg::SettingsReceived(Ok(settings)) => {
self.password_reset_enabled = Some(settings.password_reset_enabled);
}
Msg::SettingsReceived(Err(err)) => {
error!(err.to_string());
}
}
if self.user_info.is_none() {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
}
true
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
}
fn view(&self) -> Html {
let link = self.link.clone();
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link().clone();
let is_admin = self.is_admin();
let username = self.user_info.clone().map(|(username, _)| username);
let password_reset_enabled = self.password_reset_enabled;
html! {
<div class="container shadow-sm py-3">
{self.view_banner()}
<div>
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
<div class="container py-3 bg-kug">
<div class="row justify-content-center" style="padding-bottom: 80px;">
<div class="shadow-sm py-3" style="max-width: 1000px">
<Router<AppRoute>
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
<main class="py-3">
<Switch<AppRoute>
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
/>
</div>
</main>
</div>
{self.view_footer()}
</div>
</div>
}
}
}
impl App {
fn get_redirect_route() -> Option<AppRoute> {
let route_service = RouteService::<()>::new();
let current_route = route_service.get_path();
if current_route.is_empty()
|| current_route == "/"
|| current_route.contains("login")
|| current_route.contains("reset-password")
{
None
} else {
use yew_router::Switch;
AppRoute::from_route_part::<()>(current_route, None).0
}
// Get the page to land on after logging in, defaulting to the index.
fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
let route = ctx.link().history().unwrap().location().route::<AppRoute>();
route.filter(|route| {
!matches!(
route,
AppRoute::Index
| AppRoute::Login
| AppRoute::StartResetPassword
| AppRoute::FinishResetPassword { token: _ }
)
})
}
fn apply_initial_redirections(&mut self) {
let route_service = RouteService::<()>::new();
let current_route = route_service.get_path();
if current_route.contains("reset-password") {
return;
}
match &self.user_info {
None => {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
fn apply_initial_redirections(&self, ctx: &Context<Self>) {
let history = ctx.link().history().unwrap();
let route = history.location().route::<AppRoute>();
let redirection = match (route, &self.user_info, &self.redirect_to) {
(
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
_,
_,
) => {
if self.password_reset_enabled == Some(false) {
Some(AppRoute::Login)
} else {
None
}
}
Some((user_name, is_admin)) => match &self.redirect_to {
Some(url) => {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
// User is logged in, a URL was given, don't redirect.
(_, Some(_), Some(_)) => None,
(_, Some((user_name, is_admin)), None) => {
if *is_admin {
Some(AppRoute::ListUsers)
} else {
Some(AppRoute::UserDetails {
user_id: user_name.clone(),
})
}
None => {
if *is_admin {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
} else {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(
AppRoute::UserDetails(user_name.clone()),
)));
}
}
},
}
};
if let Some(redirect_to) = redirection {
history.push(redirect_to);
}
}
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
fn dispatch_route(
switch: &AppRoute,
link: &Scope<Self>,
is_admin: bool,
password_reset_enabled: Option<bool>,
) -> Html {
match switch {
AppRoute::Login => html! {
<LoginForm on_logged_in=link.callback(Msg::Login)/>
<LoginForm on_logged_in={link.callback(Msg::Login)} password_reset_enabled={password_reset_enabled.unwrap_or(false)}/>
},
AppRoute::CreateUser => html! {
<CreateUserForm/>
},
AppRoute::Index | AppRoute::ListUsers => html! {
<div>
<UserTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
</div>
},
AppRoute::Index | AppRoute::ListUsers => {
let user_button = html! {
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
<i class="bi-person-plus me-2"></i>
{"Create a user"}
</Link>
};
html! {
<div>
{ user_button.clone() }
<UserTable />
{ user_button }
</div>
}
}
AppRoute::CreateGroup => html! {
<CreateGroupForm/>
},
AppRoute::ListGroups => html! {
<div>
<GroupTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
</div>
AppRoute::CreateUserAttribute => html! {
<CreateUserAttributeForm/>
},
AppRoute::GroupDetails(group_id) => html! {
<GroupDetails group_id=group_id />
AppRoute::CreateGroupAttribute => html! {
<CreateGroupAttributeForm/>
},
AppRoute::UserDetails(username) => html! {
<UserDetails username=username is_admin=is_admin />
AppRoute::ListGroups => {
let group_button = html! {
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
<i class="bi-plus-circle me-2"></i>
{"Create a group"}
</Link>
};
// Note: There's a weird bug when switching from the users page to the groups page
// where the two groups buttons are at the bottom. I don't know why.
html! {
<div>
{ group_button.clone() }
<GroupTable />
{ group_button }
</div>
}
}
AppRoute::ListUserSchema => html! {
<ListUserSchema />
},
AppRoute::ChangePassword(username) => html! {
<ChangePasswordForm username=username is_admin=is_admin />
AppRoute::ListGroupSchema => html! {
<ListGroupSchema />
},
AppRoute::StartResetPassword => html! {
<ResetPasswordStep1Form />
AppRoute::GroupDetails { group_id } => html! {
<GroupDetails group_id={*group_id} is_admin={is_admin} />
},
AppRoute::FinishResetPassword(token) => html! {
<ResetPasswordStep2Form token=token />
AppRoute::UserDetails { user_id } => html! {
<UserDetails username={user_id.clone()} is_admin={is_admin} />
},
}
}
AppRoute::ChangePassword { user_id } => html! {
<ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
},
AppRoute::StartResetPassword => match password_reset_enabled {
Some(true) => html! { <ResetPasswordStep1Form /> },
Some(false) => {
html! { <Redirect to={AppRoute::Login}/> }
}
fn view_banner(&self) -> Html {
html! {
<header class="p-3 mb-4 border-bottom shadow-sm">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 me-md-5 text-dark text-decoration-none">
<h1>{"LLDAP"}</h1>
</a>
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
{if self.is_admin() { html! {
<>
<li>
<Link
classes="nav-link px-2 link-dark h4"
route=AppRoute::ListUsers>
{"Users"}
</Link>
</li>
<li>
<Link
classes="nav-link px-2 link-dark h4"
route=AppRoute::ListGroups>
{"Groups"}
</Link>
</li>
</>
} } else { html!{} } }
</ul>
<div class="dropdown text-end">
<a href="#"
class="d-block link-dark text-decoration-none dropdown-toggle"
id="dropdownUser"
data-bs-toggle="dropdown"
aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="currentColor"
class="bi bi-person-circle"
viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
</a>
{if let Some((user_id, _)) = &self.user_info { html! {
<ul
class="dropdown-menu text-small dropdown-menu-lg-end"
aria-labelledby="dropdownUser1"
style="">
<li>
<Link
classes="dropdown-item"
route=AppRoute::UserDetails(user_id.clone())>
{"Profile"}
</Link>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<LogoutButton on_logged_out=self.link.callback(|_| Msg::Logout) />
</li>
</ul>
} } else { html!{} } }
</div>
</div>
</div>
</header>
None => html! {},
},
AppRoute::FinishResetPassword { token } => match password_reset_enabled {
Some(true) => html! { <ResetPasswordStep2Form token={token.clone()} /> },
Some(false) => {
html! { <Redirect to={AppRoute::Login}/> }
}
None => html! {},
},
}
}
fn view_footer(&self) -> Html {
html! {
<footer class="text-center text-muted fixed-bottom bg-light">
<footer class="text-center fixed-bottom text-muted bg-light py-2">
<div>
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
</div>
<div>
<a href="https://github.com/nitnelave/lldap" class="me-4 text-reset">
<a href="https://github.com/lldap/lldap" class="me-4 text-reset">
<i class="bi-github"></i>
</a>
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
@@ -290,7 +288,7 @@ impl App {
</a>
</div>
<div>
<span>{"License "}<a href="https://github.com/nitnelave/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
<span>{"License "}<a href="https://github.com/lldap/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
</div>
</footer>
}
+88
View File
@@ -0,0 +1,88 @@
use crate::infra::functional::{LoadableResult, use_graphql_call};
use graphql_client::GraphQLQuery;
use yew::{Properties, function_component, html, virtual_dom::AttrValue};
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_user_details.graphql",
variables_derives = "Clone,PartialEq,Eq",
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetUserDetails;
#[derive(Properties, PartialEq)]
pub struct Props {
pub user: AttrValue,
#[prop_or(32)]
pub width: i32,
#[prop_or(32)]
pub height: i32,
}
#[function_component(Avatar)]
pub fn avatar(props: &Props) -> Html {
let user_details = use_graphql_call::<GetUserDetails>(get_user_details::Variables {
id: props.user.to_string(),
});
match &(*user_details) {
LoadableResult::Loaded(Ok(response)) => {
let avatar = response.user.avatar.clone();
match &avatar {
Some(data) => html! {
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", data)}
style={format!("max-height:{}px;max-width:{}px;height:auto;width:auto;", props.height, props.width)}
alt="Avatar" />
},
None => html! {
<BlankAvatarDisplay
width={props.width}
height={props.height} />
},
}
}
LoadableResult::Loaded(Err(error)) => html! {
<BlankAvatarDisplay
error={error.to_string()}
width={props.width}
height={props.height} />
},
LoadableResult::Loading => html! {
<BlankAvatarDisplay
width={props.width}
height={props.height} />
},
}
}
#[derive(Properties, PartialEq)]
struct BlankAvatarDisplayProps {
#[prop_or(None)]
pub error: Option<AttrValue>,
pub width: i32,
pub height: i32,
}
#[function_component(BlankAvatarDisplay)]
fn blank_avatar_display(props: &BlankAvatarDisplayProps) -> Html {
let fill = match &props.error {
Some(_) => "red",
None => "currentColor",
};
html! {
<svg xmlns="http://www.w3.org/2000/svg"
width={props.width.to_string()}
height={props.height.to_string()}
fill={fill}
class="bi bi-person-circle"
viewBox="0 0 16 16">
<title>{props.error.clone().unwrap_or(AttrValue::Static("Avatar"))}</title>
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
}
}
+132
View File
@@ -0,0 +1,132 @@
use crate::components::{
avatar::Avatar,
logout::LogoutButton,
router::{AppRoute, Link},
};
use wasm_bindgen::prelude::wasm_bindgen;
use yew::{Callback, Properties, function_component, html};
#[derive(Properties, PartialEq)]
pub struct Props {
pub is_admin: bool,
pub username: Option<String>,
pub on_logged_out: Callback<()>,
}
#[function_component(Banner)]
pub fn banner(props: &Props) -> Html {
html! {
<header class="p-2 mb-3 border-bottom">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
<h2>{"LLDAP"}</h2>
</a>
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
{if props.is_admin { html! {
<>
<li>
<Link
classes="nav-link px-2 h6"
to={AppRoute::ListUsers}>
<i class="bi-people me-2"></i>
{"Users"}
</Link>
</li>
<li>
<Link
classes="nav-link px-2 h6"
to={AppRoute::ListGroups}>
<i class="bi-collection me-2"></i>
{"Groups"}
</Link>
</li>
<li>
<Link
classes="nav-link px-2 h6"
to={AppRoute::ListUserSchema}>
<i class="bi-list-ul me-2"></i>
{"User schema"}
</Link>
</li>
<li>
<Link
classes="nav-link px-2 h6"
to={AppRoute::ListGroupSchema}>
<i class="bi-list-ul me-2"></i>
{"Group schema"}
</Link>
</li>
</>
} } else { html!{} } }
</ul>
<UserMenu username={props.username.clone()} on_logged_out={props.on_logged_out.clone()}/>
<DarkModeToggle />
</div>
</div>
</header>
}
}
#[derive(Properties, PartialEq)]
struct UserMenuProps {
pub username: Option<String>,
pub on_logged_out: Callback<()>,
}
#[function_component(UserMenu)]
fn user_menu(props: &UserMenuProps) -> Html {
match &props.username {
Some(username) => html! {
<div class="dropdown text-end">
<a href="#"
class="d-block nav-link text-decoration-none dropdown-toggle"
id="dropdownUser"
data-bs-toggle="dropdown"
aria-expanded="false">
<Avatar user={username.clone()} />
<span class="ms-2">
{username}
</span>
</a>
<ul
class="dropdown-menu text-small dropdown-menu-lg-end"
aria-labelledby="dropdownUser1"
style="">
<li>
<Link
classes="dropdown-item"
to={AppRoute::UserDetails{ user_id: username.to_string() }}>
{"View details"}
</Link>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<LogoutButton on_logged_out={props.on_logged_out.clone()} />
</li>
</ul>
</div>
},
_ => html! {},
}
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = darkmode)]
fn toggleDarkMode(doSave: bool);
#[wasm_bindgen]
fn inDarkMode() -> bool;
}
#[function_component(DarkModeToggle)]
fn dark_mode_toggle() -> Html {
html! {
<div class="form-check form-switch">
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
</div>
}
}
+94 -137
View File
@@ -1,34 +1,30 @@
use crate::{
components::router::{AppRoute, NavButton},
components::{
form::{field::Field, submit::Submit},
router::{AppRoute, Link},
},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{anyhow, bail, Context, Result};
use anyhow::{Result, anyhow, bail};
use gloo_console::error;
use lldap_auth::*;
use validator_derive::Validate;
use yew::{prelude::*, services::ConsoleService};
use yew::prelude::*;
use yew_form::Form;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(PartialEq, Eq)]
#[derive(PartialEq, Eq, Default)]
enum OpaqueData {
#[default]
None,
Login(opaque::client::login::ClientLogin),
Registration(opaque::client::registration::ClientRegistration),
}
impl Default for OpaqueData {
fn default() -> Self {
OpaqueData::None
}
}
impl OpaqueData {
fn take(&mut self) -> Self {
std::mem::take(self)
@@ -61,7 +57,6 @@ pub struct ChangePasswordForm {
common: CommonComponentParts<Self>,
form: Form<FormModel>,
opaque_data: OpaqueData,
route_dispatcher: RouteAgentDispatcher,
}
#[derive(Clone, PartialEq, Eq, Properties)]
@@ -80,15 +75,20 @@ pub enum Msg {
}
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
use anyhow::Context;
match msg {
Msg::FormUpdate => Ok(true),
Msg::Submit => {
if !self.form.validate() {
bail!("Check the form for errors");
}
if self.common.is_admin {
self.handle_msg(Msg::SubmitNewPassword)
if ctx.props().is_admin {
self.handle_msg(ctx, Msg::SubmitNewPassword)
} else {
let old_password = self.form.model().old_password;
if old_password.is_empty() {
@@ -100,14 +100,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
.context("Could not initialize login")?;
self.opaque_data = OpaqueData::Login(login_start_request.state);
let req = login::ClientLoginStartRequest {
username: self.common.username.clone(),
username: ctx.props().username.clone().into(),
login_start_request: login_start_request.message,
};
self.common.call_backend(
HostService::login_start,
req,
ctx,
HostService::login_start(req),
Msg::AuthenticationStartResponse,
)?;
);
Ok(true)
}
}
@@ -119,34 +119,33 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|e| {
// Common error, we want to print a full error to the console but only a
// simple one to the user.
ConsoleService::error(&format!(
"Invalid username or password: {}",
e
));
error!(&format!("Invalid username or password: {}", e));
anyhow!("Invalid username or password")
},
)?;
}
_ => panic!("Unexpected data in opaque_data field"),
};
self.handle_msg(Msg::SubmitNewPassword)
self.handle_msg(ctx, Msg::SubmitNewPassword)
}
Msg::SubmitNewPassword => {
let mut rng = rand::rngs::OsRng;
let new_password = self.form.model().password;
let registration_start_request =
opaque::client::registration::start_registration(&new_password, &mut rng)
.context("Could not initiate password change")?;
let registration_start_request = opaque::client::registration::start_registration(
new_password.as_bytes(),
&mut rng,
)
.context("Could not initiate password change")?;
let req = registration::ClientRegistrationStartRequest {
username: self.common.username.clone(),
username: ctx.props().username.clone().into(),
registration_start_request: registration_start_request.message,
};
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
self.common.call_backend(
HostService::register_start,
req,
ctx,
HostService::register_start(req),
Msg::RegistrationStartResponse,
)?;
);
Ok(true)
}
Msg::RegistrationStartResponse(res) => {
@@ -166,22 +165,20 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
registration_upload: registration_finish.message,
};
self.common.call_backend(
HostService::register_finish,
req,
ctx,
HostService::register_finish(req),
Msg::RegistrationFinishResponse,
)
);
}
_ => panic!("Unexpected data in opaque_data field"),
}?;
};
Ok(false)
}
Msg::RegistrationFinishResponse(response) => {
self.common.cancel_task();
if response.is_ok() {
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(
AppRoute::UserDetails(self.common.username.clone()),
)));
ctx.link().history().unwrap().push(AppRoute::UserDetails {
user_id: ctx.props().username.clone(),
});
}
response?;
Ok(true)
@@ -198,116 +195,76 @@ impl Component for ChangePasswordForm {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
ChangePasswordForm {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<FormModel>::new(FormModel::default()),
opaque_data: OpaqueData::None,
route_dispatcher: RouteAgentDispatcher::new(),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
let is_admin = self.common.is_admin;
type Field = yew_form::Field<FormModel>;
fn view(&self, ctx: &Context<Self>) -> Html {
let is_admin = ctx.props().is_admin;
let link = ctx.link();
html! {
<>
<form
class="form">
{if !is_admin { html! {
<div class="form-group row">
<label for="old_password"
class="form-label col-sm-2 col-form-label">
{"Current password*:"}
</label>
<div class="col-sm-10">
<Field
form=&self.form
field_name="old_password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="current-password"
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("old_password")}
</div>
</div>
</div>
}} else { html! {} }}
<div class="form-group row">
<label for="new_password"
class="form-label col-sm-2 col-form-label">
{"New password*:"}
</label>
<div class="col-sm-10">
<Field
form=&self.form
field_name="password"
input_type="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("password")}
</div>
</div>
</div>
<div class="form-group row">
<label for="confirm_password"
class="form-label col-sm-2 col-form-label">
{"Confirm password*:"}
</label>
<div class="col-sm-10">
<Field
form=&self.form
field_name="confirm_password"
input_type="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("confirm_password")}
</div>
</div>
</div>
<div class="form-group row">
<button
class="btn btn-primary col-sm-1 col-form-label"
type="submit"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Submit"}
</button>
</div>
</form>
{ if let Some(e) = &self.common.error {
<div class="mb-2 mt-2">
<h5 class="fw-bold">
{"Change password"}
</h5>
</div>
{
if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
<div class="alert alert-danger mt-3 mb-3">
{e.to_string() }
</div>
}
} else { html! {} }
}
<div>
<NavButton
classes="btn btn-primary"
route=AppRoute::UserDetails(self.common.username.clone())>
{"Back"}
</NavButton>
</div>
<form class="form">
{if !is_admin { html! {
<Field<FormModel>
form={&self.form}
required=true
label="Current password"
field_name="old_password"
input_type="password"
autocomplete="current-password"
oninput={link.callback(|_| Msg::FormUpdate)} />
}} else { html! {} }}
<Field<FormModel>
form={&self.form}
required=true
label="New password"
field_name="password"
input_type="password"
autocomplete="new-password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<Field<FormModel>
form={&self.form}
required=true
label="Confirm password"
field_name="confirm_password"
input_type="password"
autocomplete="new-password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}
text="Save changes" >
<Link
classes="btn btn-secondary ms-2 col-auto col-form-label"
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
<i class="bi-arrow-return-left me-2"></i>
{"Back"}
</Link>
</Submit>
</form>
</>
}
}
+143 -60
View File
@@ -1,17 +1,54 @@
use crate::{
components::router::AppRoute,
infra::common_component::{CommonComponent, CommonComponentParts},
components::{
form::{
attribute_input::{ListAttributeInput, SingleAttributeInput},
field::Field,
submit::Submit,
},
router::AppRoute,
},
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
read_all_form_attributes,
},
schema::AttributeType,
},
};
use anyhow::{bail, Result};
use anyhow::{Result, ensure};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use yew::prelude::*;
use yew::services::ConsoleService;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_group_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetGroupAttributesSchema;
use get_group_attributes_schema::ResponseData;
pub type Attribute =
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
impl From<&Attribute> for GraphQlAttributeSchema {
fn from(attr: &Attribute) -> Self {
Self {
name: attr.name.clone(),
is_list: attr.is_list,
is_readonly: attr.is_readonly,
is_editable: false, // Need to be admin to edit it.
}
}
}
#[derive(GraphQLQuery)]
#[graphql(
@@ -24,8 +61,9 @@ pub struct CreateGroup;
pub struct CreateGroupForm {
common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateGroupModel>,
attributes_schema: Option<Vec<Attribute>>,
form_ref: NodeRef,
}
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
@@ -36,23 +74,50 @@ pub struct CreateGroupModel {
pub enum Msg {
Update,
ListAttributesResponse(Result<ResponseData>),
SubmitForm,
CreateGroupResponse(Result<create_group::ResponseData>),
}
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitForm => {
if !self.form.validate() {
bail!("Check the form for errors");
}
ensure!(self.form.validate(), "Check the form for errors");
let all_values = read_all_form_attributes(
self.attributes_schema.iter().flatten(),
&self.form_ref,
IsAdmin(true),
EmailIsRequired(false),
)?;
let attributes = Some(
all_values
.into_iter()
.filter(|a| !a.values.is_empty())
.map(
|AttributeValue { name, values }| create_group::AttributeValueInput {
name,
value: values,
},
)
.collect(),
);
let model = self.form.model();
let req = create_group::Variables {
name: model.groupname,
group: create_group::CreateGroupInput {
displayName: model.groupname,
attributes,
},
};
self.common.call_graphql::<CreateGroup, _>(
ctx,
req,
Msg::CreateGroupResponse,
"Error trying to create group",
@@ -60,12 +125,16 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
Ok(true)
}
Msg::CreateGroupResponse(response) => {
ConsoleService::log(&format!(
log!(&format!(
"Created group '{}'",
&response?.create_group.display_name
&response?.create_group_with_details.display_name
));
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
ctx.link().history().unwrap().push(AppRoute::ListGroups);
Ok(true)
}
Msg::ListAttributesResponse(schema) => {
self.attributes_schema =
Some(schema?.schema.group_schema.attributes.into_iter().collect());
Ok(true)
}
}
@@ -80,58 +149,54 @@ impl Component for CreateGroupForm {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(props, link),
route_dispatcher: RouteAgentDispatcher::new(),
fn create(ctx: &Context<Self>) -> Self {
let mut component = Self {
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
}
attributes_schema: None,
form_ref: NodeRef::default(),
};
component
.common
.call_graphql::<GetGroupAttributesSchema, _>(
ctx,
get_group_attributes_schema::Variables {},
Msg::ListAttributesResponse,
"Error trying to fetch group schema",
);
component
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
type Field = yew_form::Field<CreateGroupModel>;
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="row justify-content-center">
<form class="form shadow-sm py-3" style="max-width: 636px">
<form class="form py-3" style="max-width: 636px"
ref={self.form_ref.clone()}>
<div class="row mb-3">
<h5 class="fw-bold">{"Create a group"}</h5>
</div>
<div class="form-group row mb-3">
<label for="groupname"
class="form-label col-4 col-form-label">
{"Group name*:"}
</label>
<div class="col-8">
<Field
form=&self.form
field_name="groupname"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="groupname"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("groupname")}
</div>
</div>
</div>
<div class="form-group row justify-content-center">
<button
class="btn btn-primary col-auto col-form-label"
type="submit"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
{"Submit"}
</button>
</div>
<Field<CreateGroupModel>
form={&self.form}
required=true
label="Group name"
field_name="groupname"
oninput={link.callback(|_| Msg::Update)} />
{
self.attributes_schema
.iter()
.flatten()
.filter(|a| !a.is_readonly && a.name != "display_name")
.map(get_custom_attribute_input)
.collect::<Vec<_>>()
}
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
</form>
{ if let Some(e) = &self.common.error {
html! {
@@ -145,3 +210,21 @@ impl Component for CreateGroupForm {
}
}
}
fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
if attribute_schema.is_list {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
/>
}
}
}
@@ -0,0 +1,175 @@
use crate::{
components::{
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
router::AppRoute,
},
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::{AttributeType, validate_attribute_type},
},
};
use anyhow::{Result, bail};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use lldap_validation::attributes::validate_attribute_name;
use validator_derive::Validate;
use yew::prelude::*;
use yew_form_derive::Model;
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/create_group_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct CreateGroupAttribute;
pub struct CreateGroupAttributeForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateGroupAttributeModel>,
}
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
pub struct CreateGroupAttributeModel {
#[validate(length(min = 1, message = "attribute_name is required"))]
attribute_name: String,
#[validate(custom = "validate_attribute_type")]
attribute_type: String,
is_list: bool,
is_visible: bool, // remove when backend doesn't return group attributes for normal users
}
pub enum Msg {
Update,
SubmitForm,
CreateGroupAttributeResponse(Result<create_group_attribute::ResponseData>),
}
impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitForm => {
if !self.form.validate() {
bail!("Check the form for errors");
}
let model = self.form.model();
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
let invalid = String::from_iter(invalid_chars);
bail!(
"Attribute name contains one or more invalid characters: {}",
invalid
);
})?;
let attribute_type =
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
let req = create_group_attribute::Variables {
name: model.attribute_name,
attribute_type,
is_list: model.is_list,
is_visible: model.is_visible,
};
self.common.call_graphql::<CreateGroupAttribute, _>(
ctx,
req,
Msg::CreateGroupAttributeResponse,
"Error trying to create group attribute",
);
Ok(true)
}
Msg::CreateGroupAttributeResponse(response) => {
response?;
let model = self.form.model();
log!(&format!(
"Created group attribute '{}'",
model.attribute_name
));
ctx.link()
.history()
.unwrap()
.push(AppRoute::ListGroupSchema);
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for CreateGroupAttributeForm {
type Message = Msg;
type Properties = ();
fn create(_: &Context<Self>) -> Self {
let model = CreateGroupAttributeModel {
attribute_type: AttributeType::String.to_string(),
..Default::default()
};
Self {
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateGroupAttributeModel>::new(model),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="row justify-content-center">
<form class="form py-3" style="max-width: 636px">
<h5 class="fw-bold">{"Create a group attribute"}</h5>
<Field<CreateGroupAttributeModel>
label="Name"
required={true}
form={&self.form}
field_name="attribute_name"
oninput={link.callback(|_| Msg::Update)} />
<Select<CreateGroupAttributeModel>
label="Type"
required={true}
form={&self.form}
field_name="attribute_type"
oninput={link.callback(|_| Msg::Update)}>
<option selected=true value="String">{"String"}</option>
<option value="Integer">{"Integer"}</option>
<option value="JpegPhoto">{"Jpeg"}</option>
<option value="DateTime">{"DateTime"}</option>
</Select<CreateGroupAttributeModel>>
<CheckBox<CreateGroupAttributeModel>
label="Multiple values"
form={&self.form}
field_name="is_list"
ontoggle={link.callback(|_| Msg::Update)} />
<CheckBox<CreateGroupAttributeModel>
label="Visible to users"
form={&self.form}
field_name="is_visible"
ontoggle={link.callback(|_| Msg::Update)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
</form>
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
</div>
}
}
}
+167 -203
View File
@@ -1,21 +1,55 @@
use crate::{
components::router::AppRoute,
components::{
form::{
attribute_input::{ListAttributeInput, SingleAttributeInput},
field::Field,
submit::Submit,
},
router::AppRoute,
},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
form_utils::{
AttributeValue, EmailIsRequired, GraphQlAttributeSchema, IsAdmin,
read_all_form_attributes,
},
schema::AttributeType,
},
};
use anyhow::{bail, Context, Result};
use anyhow::{Result, ensure};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use lldap_auth::{opaque, registration};
use validator_derive::Validate;
use yew::prelude::*;
use yew::services::ConsoleService;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_user_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetUserAttributesSchema;
use get_user_attributes_schema::ResponseData;
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
impl From<&Attribute> for GraphQlAttributeSchema {
fn from(attr: &Attribute) -> Self {
Self {
name: attr.name.clone(),
is_list: attr.is_list,
is_readonly: attr.is_readonly,
is_editable: attr.is_editable,
}
}
}
#[derive(GraphQLQuery)]
#[graphql(
@@ -28,20 +62,15 @@ pub struct CreateUser;
pub struct CreateUserForm {
common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateUserModel>,
attributes_schema: Option<Vec<Attribute>>,
form_ref: NodeRef,
}
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
pub struct CreateUserModel {
#[validate(length(min = 1, message = "Username is required"))]
username: String,
#[validate(email(message = "A valid email is required"))]
email: String,
#[validate(length(min = 1, message = "Display name is required"))]
display_name: String,
first_name: String,
last_name: String,
#[validate(custom(
function = "empty_or_long",
message = "Password should be longer than 8 characters (or left empty)"
@@ -61,6 +90,7 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
pub enum Msg {
Update,
ListAttributesResponse(Result<ResponseData>),
SubmitForm,
CreateUserResponse(Result<create_user::ResponseData>),
SuccessfulCreation,
@@ -74,26 +104,54 @@ pub enum Msg {
}
impl CommonComponent<CreateUserForm> for CreateUserForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::ListAttributesResponse(schema) => {
self.attributes_schema =
Some(schema?.schema.user_schema.attributes.into_iter().collect());
Ok(true)
}
Msg::SubmitForm => {
if !self.form.validate() {
bail!("Check the form for errors");
}
ensure!(self.form.validate(), "Check the form for errors");
let all_values = read_all_form_attributes(
self.attributes_schema.iter().flatten(),
&self.form_ref,
IsAdmin(true),
EmailIsRequired(true),
)?;
let attributes = Some(
all_values
.into_iter()
.filter(|a| !a.values.is_empty())
.map(
|AttributeValue { name, values }| create_user::AttributeValueInput {
name,
value: values,
},
)
.collect(),
);
let model = self.form.model();
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
let req = create_user::Variables {
user: create_user::CreateUserInput {
id: model.username,
email: model.email,
displayName: to_option(model.display_name),
firstName: to_option(model.first_name),
lastName: to_option(model.last_name),
email: None,
displayName: None,
firstName: None,
lastName: None,
avatar: None,
attributes,
},
};
self.common.call_graphql::<CreateUser, _>(
ctx,
req,
Msg::CreateUserResponse,
"Error trying to create user",
@@ -103,7 +161,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
Msg::CreateUserResponse(r) => {
match r {
Err(e) => return Err(e),
Ok(r) => ConsoleService::log(&format!(
Ok(r) => log!(&format!(
"Created user '{}' at '{}'",
&r.create_user.id, &r.create_user.creation_date
)),
@@ -117,18 +175,20 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
let opaque::client::registration::ClientRegistrationStartResult {
state,
message,
} = opaque::client::registration::start_registration(&password, &mut rng)?;
} = opaque::client::registration::start_registration(
password.as_bytes(),
&mut rng,
)?;
let req = registration::ClientRegistrationStartRequest {
username: user_id,
username: user_id.into(),
registration_start_request: message,
};
self.common
.call_backend(HostService::register_start, req, move |r| {
.call_backend(ctx, HostService::register_start(req), move |r| {
Msg::RegistrationStartResponse((state, r))
})
.context("Error trying to create user")?;
});
} else {
self.update(Msg::SuccessfulCreation);
self.update(ctx, Msg::SuccessfulCreation);
}
Ok(false)
}
@@ -144,22 +204,19 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
server_data: response.server_data,
registration_upload: registration_upload.message,
};
self.common
.call_backend(
HostService::register_finish,
req,
Msg::RegistrationFinishResponse,
)
.context("Error trying to register user")?;
self.common.call_backend(
ctx,
HostService::register_finish(req),
Msg::RegistrationFinishResponse,
);
Ok(false)
}
Msg::RegistrationFinishResponse(response) => {
response?;
self.handle_msg(Msg::SuccessfulCreation)
self.handle_msg(ctx, Msg::SuccessfulCreation)
}
Msg::SuccessfulCreation => {
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListUsers)));
ctx.link().history().unwrap().push(AppRoute::ListUsers);
Ok(true)
}
}
@@ -174,177 +231,66 @@ impl Component for CreateUserForm {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(props, link),
route_dispatcher: RouteAgentDispatcher::new(),
fn create(ctx: &Context<Self>) -> Self {
let mut component = Self {
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
}
attributes_schema: None,
form_ref: NodeRef::default(),
};
component.common.call_graphql::<GetUserAttributesSchema, _>(
ctx,
get_user_attributes_schema::Variables {},
Msg::ListAttributesResponse,
"Error trying to fetch user schema",
);
component
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
type Field = yew_form::Field<CreateUserModel>;
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<div class="row justify-content-center">
<form class="form shadow-sm py-3" style="max-width: 636px">
<div class="row mb-3">
<h5 class="fw-bold">{"Create a user"}</h5>
</div>
<div class="form-group row mb-3">
<label for="username"
class="form-label col-4 col-form-label">
{"User name*:"}
</label>
<div class="col-8">
<Field
form=&self.form
field_name="username"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="username"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("username")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="email"
class="form-label col-4 col-form-label">
{"Email*:"}
</label>
<div class="col-8">
<Field
form=&self.form
input_type="email"
field_name="email"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="email"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("email")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="display-name"
class="form-label col-4 col-form-label">
{"Display name*:"}
</label>
<div class="col-8">
<Field
form=&self.form
autocomplete="name"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
field_name="display_name"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("display_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="first-name"
class="form-label col-4 col-form-label">
{"First name:"}
</label>
<div class="col-8">
<Field
form=&self.form
autocomplete="given-name"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
field_name="first_name"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("first_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="last-name"
class="form-label col-4 col-form-label">
{"Last name:"}
</label>
<div class="col-8">
<Field
form=&self.form
autocomplete="family-name"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
field_name="last_name"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("last_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="password"
class="form-label col-4 col-form-label">
{"Password:"}
</label>
<div class="col-8">
<Field
form=&self.form
input_type="password"
field_name="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("password")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="confirm_password"
class="form-label col-4 col-form-label">
{"Confirm password:"}
</label>
<div class="col-8">
<Field
form=&self.form
input_type="password"
field_name="confirm_password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("confirm_password")}
</div>
</div>
</div>
<div class="form-group row justify-content-center">
<button
class="btn btn-primary col-auto col-form-label mt-4"
disabled=self.common.is_task_running()
type="submit"
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
{"Submit"}
</button>
</div>
<form class="form py-3"
ref={self.form_ref.clone()}>
<Field<CreateUserModel>
form={&self.form}
required=true
label="User name"
field_name="username"
oninput={link.callback(|_| Msg::Update)} />
{
self.attributes_schema
.iter()
.flatten()
.filter(|a| !a.is_readonly)
.map(get_custom_attribute_input)
.collect::<Vec<_>>()
}
<Field<CreateUserModel>
form={&self.form}
label="Password"
field_name="password"
input_type="password"
autocomplete="new-password"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
label="Confirm password"
field_name="confirm_password"
input_type="password"
autocomplete="new-password"
oninput={link.callback(|_| Msg::Update)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
</form>
{ if let Some(e) = &self.common.error {
{
if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
@@ -356,3 +302,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={attribute_schema.attribute_type}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
/>
}
}
}
+182
View File
@@ -0,0 +1,182 @@
use crate::{
components::{
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
router::AppRoute,
},
infra::{
common_component::{CommonComponent, CommonComponentParts},
schema::{AttributeType, validate_attribute_type},
},
};
use anyhow::{Result, bail};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use lldap_validation::attributes::validate_attribute_name;
use validator_derive::Validate;
use yew::prelude::*;
use yew_form_derive::Model;
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/create_user_attribute.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct CreateUserAttribute;
pub struct CreateUserAttributeForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateUserAttributeModel>,
}
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
pub struct CreateUserAttributeModel {
#[validate(length(min = 1, message = "attribute_name is required"))]
attribute_name: String,
#[validate(custom = "validate_attribute_type")]
attribute_type: String,
is_editable: bool,
is_list: bool,
is_visible: bool,
}
pub enum Msg {
Update,
SubmitForm,
CreateUserAttributeResponse(Result<create_user_attribute::ResponseData>),
}
impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitForm => {
if !self.form.validate() {
bail!("Check the form for errors");
}
let model = self.form.model();
if model.is_editable && !model.is_visible {
bail!("Editable attributes must also be visible");
}
validate_attribute_name(&model.attribute_name).or_else(|invalid_chars| {
let invalid = String::from_iter(invalid_chars);
bail!(
"Attribute name contains one or more invalid characters: {}",
invalid
);
})?;
let attribute_type =
AttributeType::try_from(model.attribute_type.as_str()).unwrap();
let req = create_user_attribute::Variables {
name: model.attribute_name,
attribute_type,
is_editable: model.is_editable,
is_list: model.is_list,
is_visible: model.is_visible,
};
self.common.call_graphql::<CreateUserAttribute, _>(
ctx,
req,
Msg::CreateUserAttributeResponse,
"Error trying to create user attribute",
);
Ok(true)
}
Msg::CreateUserAttributeResponse(response) => {
response?;
let model = self.form.model();
log!(&format!(
"Created user attribute '{}'",
model.attribute_name
));
ctx.link().history().unwrap().push(AppRoute::ListUserSchema);
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for CreateUserAttributeForm {
type Message = Msg;
type Properties = ();
fn create(_: &Context<Self>) -> Self {
let model = CreateUserAttributeModel {
attribute_type: AttributeType::String.to_string(),
..Default::default()
};
Self {
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateUserAttributeModel>::new(model),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="row justify-content-center">
<form class="form py-3" style="max-width: 636px">
<h5 class="fw-bold">{"Create a user attribute"}</h5>
<Field<CreateUserAttributeModel>
label="Name"
required={true}
form={&self.form}
field_name="attribute_name"
oninput={link.callback(|_| Msg::Update)} />
<Select<CreateUserAttributeModel>
label="Type"
required={true}
form={&self.form}
field_name="attribute_type"
oninput={link.callback(|_| Msg::Update)}>
<option selected=true value="String">{"String"}</option>
<option value="Integer">{"Integer"}</option>
<option value="JpegPhoto">{"Jpeg"}</option>
<option value="DateTime">{"DateTime"}</option>
</Select<CreateUserAttributeModel>>
<CheckBox<CreateUserAttributeModel>
label="Multiple values"
form={&self.form}
field_name="is_list"
ontoggle={link.callback(|_| Msg::Update)} />
<CheckBox<CreateUserAttributeModel>
label="Visible to users"
form={&self.form}
field_name="is_visible"
ontoggle={link.callback(|_| Msg::Update)} />
<CheckBox<CreateUserAttributeModel>
label="Editable by users"
form={&self.form}
field_name="is_editable"
ontoggle={link.callback(|_| Msg::Update)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
</form>
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
</div>
}
}
}
+33 -29
View File
@@ -39,16 +39,21 @@ pub enum Msg {
}
impl CommonComponent<DeleteGroup> for DeleteGroup {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::ClickedDeleteGroup => {
self.modal.as_ref().expect("modal not initialized").show();
}
Msg::ConfirmDeleteGroup => {
self.update(Msg::DismissModal);
self.update(ctx, Msg::DismissModal);
self.common.call_graphql::<DeleteGroupQuery, _>(
ctx,
delete_group_query::Variables {
group_id: self.common.group.id,
group_id: ctx.props().group.id,
},
Msg::DeleteGroupResponse,
"Error trying to delete group",
@@ -58,12 +63,8 @@ impl CommonComponent<DeleteGroup> for DeleteGroup {
self.modal.as_ref().expect("modal not initialized").hide();
}
Msg::DeleteGroupResponse(response) => {
self.common.cancel_task();
response?;
self.common
.props
.on_group_deleted
.emit(self.common.group.id);
ctx.props().on_group_deleted.emit(ctx.props().group.id);
}
}
Ok(true)
@@ -78,15 +79,15 @@ impl Component for DeleteGroup {
type Message = Msg;
type Properties = DeleteGroupProps;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
node_ref: NodeRef::default(),
modal: None,
}
}
fn rendered(&mut self, first_render: bool) {
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
if first_render {
self.modal = Some(Modal::new(
self.node_ref
@@ -96,43 +97,42 @@ impl Component for DeleteGroup {
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error(
self,
ctx,
msg,
self.common.on_error.clone(),
ctx.props().on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<>
<button
class="btn btn-danger"
disabled=self.common.is_task_running()
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
disabled={self.common.is_task_running()}
onclick={link.callback(|_| Msg::ClickedDeleteGroup)}>
<i class="bi-x-circle-fill" aria-label="Delete group" />
</button>
{self.show_modal()}
{self.show_modal(ctx)}
</>
}
}
}
impl DeleteGroup {
fn show_modal(&self) -> Html {
fn show_modal(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<div
class="modal fade"
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
id={"deleteGroupModal".to_string() + &ctx.props().group.id.to_string()}
tabindex="-1"
aria-labelledby="deleteGroupModalLabel"
aria-hidden="true"
ref=self.node_ref.clone()>
ref={self.node_ref.clone()}>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -141,25 +141,29 @@ impl DeleteGroup {
type="button"
class="btn-close"
aria-label="Close"
onclick=self.common.callback(|_| Msg::DismissModal) />
onclick={link.callback(|_| Msg::DismissModal)} />
</div>
<div class="modal-body">
<span>
{"Are you sure you want to delete group "}
<b>{&self.common.group.display_name}</b>{"?"}
<b>{&ctx.props().group.display_name}</b>{"?"}
</span>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick=self.common.callback(|_| Msg::DismissModal)>
onclick={link.callback(|_| Msg::DismissModal)}>
<i class="bi-x-circle me-2"></i>
{"Cancel"}
</button>
<button
type="button"
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
class="btn btn-danger">{"Yes, I'm sure"}</button>
onclick={link.callback(|_| Msg::ConfirmDeleteGroup)}
class="btn btn-danger">
<i class="bi-check-circle me-2"></i>
{"Yes, I'm sure"}
</button>
</div>
</div>
</div>
@@ -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>
}
}
}
+35 -29
View File
@@ -36,16 +36,21 @@ pub enum Msg {
}
impl CommonComponent<DeleteUser> for DeleteUser {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::ClickedDeleteUser => {
self.modal.as_ref().expect("modal not initialized").show();
}
Msg::ConfirmDeleteUser => {
self.update(Msg::DismissModal);
self.update(ctx, Msg::DismissModal);
self.common.call_graphql::<DeleteUserQuery, _>(
ctx,
delete_user_query::Variables {
user: self.common.username.clone(),
user: ctx.props().username.clone(),
},
Msg::DeleteUserResponse,
"Error trying to delete user",
@@ -55,12 +60,10 @@ impl CommonComponent<DeleteUser> for DeleteUser {
self.modal.as_ref().expect("modal not initialized").hide();
}
Msg::DeleteUserResponse(response) => {
self.common.cancel_task();
response?;
self.common
.props
ctx.props()
.on_user_deleted
.emit(self.common.username.clone());
.emit(ctx.props().username.clone());
}
}
Ok(true)
@@ -75,15 +78,15 @@ impl Component for DeleteUser {
type Message = Msg;
type Properties = DeleteUserProps;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
node_ref: NodeRef::default(),
modal: None,
}
}
fn rendered(&mut self, first_render: bool) {
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
if first_render {
self.modal = Some(Modal::new(
self.node_ref
@@ -93,44 +96,43 @@ impl Component for DeleteUser {
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error(
self,
ctx,
msg,
self.common.on_error.clone(),
ctx.props().on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<>
<button
class="btn btn-danger"
disabled=self.common.is_task_running()
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
disabled={self.common.is_task_running()}
onclick={link.callback(|_| Msg::ClickedDeleteUser)}>
<i class="bi-x-circle-fill" aria-label="Delete user" />
</button>
{self.show_modal()}
{self.show_modal(ctx)}
</>
}
}
}
impl DeleteUser {
fn show_modal(&self) -> Html {
fn show_modal(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<div
class="modal fade"
id="deleteUserModal".to_string() + &self.common.username
id={"deleteUserModal".to_string() + &ctx.props().username}
tabindex="-1"
//role="dialog"
aria-labelledby="deleteUserModalLabel"
aria-hidden="true"
ref=self.node_ref.clone()>
ref={self.node_ref.clone()}>
<div class="modal-dialog" /*role="document"*/>
<div class="modal-content">
<div class="modal-header">
@@ -139,25 +141,29 @@ impl DeleteUser {
type="button"
class="btn-close"
aria-label="Close"
onclick=self.common.callback(|_| Msg::DismissModal) />
onclick={link.callback(|_| Msg::DismissModal)} />
</div>
<div class="modal-body">
<span>
{"Are you sure you want to delete user "}
<b>{&self.common.username}</b>{"?"}
<b>{&ctx.props().username}</b>{"?"}
</span>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick=self.common.callback(|_| Msg::DismissModal)>
{"Cancel"}
onclick={link.callback(|_| Msg::DismissModal)}>
<i class="bi-x-circle me-2"></i>
{"Cancel"}
</button>
<button
type="button"
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
class="btn btn-danger">{"Yes, I'm sure"}</button>
onclick={link.callback(|_| Msg::ConfirmDeleteUser)}
class="btn btn-danger">
<i class="bi-check-circle me-2"></i>
{"Yes, I'm sure"}
</button>
</div>
</div>
</div>
+172
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>
}
}
}
+190
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::{
Component, Context, Html, Properties, function_component, html, use_effect_with_deps,
use_node_ref, virtual_dom::AttrValue,
};
#[derive(Properties, PartialEq)]
struct AttributeInputProps {
name: AttrValue,
attribute_type: AttributeType,
#[prop_or(None)]
value: Option<String>,
}
#[function_component(AttributeInput)]
fn attribute_input(props: &AttributeInputProps) -> Html {
let input_type = match props.attribute_type {
AttributeType::String => "text",
AttributeType::Integer => "number",
AttributeType::DateTime => {
return html! {
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
};
}
AttributeType::JpegPhoto => {
return html! {
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
};
}
};
html! {
<input
type={input_type}
name={props.name.clone()}
class="form-control"
value={props.value.clone()} />
}
}
#[derive(Properties, PartialEq)]
struct AttributeLabelProps {
pub name: String,
}
#[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(crate) 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}
name={props.name.clone()}
value={props.value.clone()} />
</div>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct ListAttributeInputProps {
pub name: String,
pub(crate) attribute_type: AttributeType,
#[prop_or(vec!())]
pub values: Vec<String>,
}
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}
name={props.name.clone()}
value={props.values.get(i).cloned().unwrap_or_default()} />
<button
class="btn btn-danger"
type="button"
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
<i class="bi-x-circle-fill" aria-label="Remove value" />
</button>
</div>
}).collect::<Html>()}
<button
class="btn btn-secondary"
type="button"
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
<i class="bi-plus-circle me-2"></i>
{"Add value"}
</button>
</div>
</div>
}
}
}
+35
View File
@@ -0,0 +1,35 @@
use yew::{Callback, Properties, function_component, html, virtual_dom::AttrValue};
use yew_form::{Form, Model};
#[derive(Properties, PartialEq)]
pub struct Props<T: Model> {
pub label: AttrValue,
pub field_name: String,
pub form: Form<T>,
#[prop_or(false)]
pub required: bool,
#[prop_or_else(Callback::noop)]
pub ontoggle: Callback<bool>,
}
#[function_component(CheckBox)]
pub fn checkbox<T: Model>(props: &Props<T>) -> Html {
html! {
<div class="form-group row mb-3">
<label for={props.field_name.clone()}
class="form-label col-4 col-form-label">
{&props.label}
{if props.required {
html!{<span class="text-danger">{"*"}</span>}
} else {html!{}}}
{":"}
</label>
<div class="col-8">
<yew_form::CheckBox<T>
form={&props.form}
field_name={props.field_name.clone()}
ontoggle={props.ontoggle.clone()} />
</div>
</div>
}
}
+49
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::{Event, Properties, function_component, html, use_state, virtual_dom::AttrValue};
#[derive(Properties, PartialEq)]
pub struct DateTimeInputProps {
pub name: AttrValue,
pub value: Option<String>,
}
#[function_component(DateTimeInput)]
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
let value = use_state(|| {
props
.value
.as_ref()
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
});
html! {
<div class="input-group">
<input
type="hidden"
name={props.name.clone()}
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
<input
type="datetime-local"
step="1"
class="form-control"
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
onchange={move |e: Event| {
let string_val =
e.target()
.expect("Event should have target")
.unchecked_into::<HtmlInputElement>()
.value();
value.set(
NaiveDateTime::from_str(&string_val)
.ok()
.map(|x| DateTime::from_naive_utc_and_offset(x, Utc))
)
}} />
<span class="input-group-text">{"UTC"}</span>
</div>
}
}
+48
View File
@@ -0,0 +1,48 @@
use yew::{Callback, InputEvent, Properties, function_component, html, virtual_dom::AttrValue};
use yew_form::{Form, Model};
#[derive(Properties, PartialEq)]
pub struct Props<T: Model> {
pub label: AttrValue,
pub field_name: String,
pub form: Form<T>,
#[prop_or(false)]
pub required: bool,
#[prop_or(String::from("text"))]
pub input_type: String,
// If not present, will default to field_name
#[prop_or(None)]
pub autocomplete: Option<String>,
#[prop_or_else(Callback::noop)]
pub oninput: Callback<InputEvent>,
}
#[function_component(Field)]
pub fn field<T: Model>(props: &Props<T>) -> Html {
html! {
<div class="row mb-3">
<label for={props.field_name.clone()}
class="form-label col-4 col-form-label">
{&props.label}
{if props.required {
html!{<span class="text-danger">{"*"}</span>}
} else {html!{}}}
{":"}
</label>
<div class="col-8">
<yew_form::Field<T>
form={&props.form}
field_name={props.field_name.clone()}
input_type={props.input_type.clone()}
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete={props.autocomplete.clone().unwrap_or(props.field_name.clone())}
oninput={&props.oninput} />
<div class="invalid-feedback">
{&props.form.field_message(&props.field_name)}
</div>
</div>
</div>
}
}
+238
View File
@@ -0,0 +1,238 @@
use std::{fmt::Display, str::FromStr};
use anyhow::{Error, Ok, Result, bail};
use gloo_file::{
File,
callbacks::{FileReader, read_as_bytes},
};
use web_sys::{FileList, HtmlInputElement, InputEvent};
use yew::Properties;
use yew::{prelude::*, virtual_dom::AttrValue};
#[derive(Default)]
struct JsFile {
file: Option<File>,
contents: Option<Vec<u8>>,
}
impl Display for JsFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
self.file.as_ref().map(File::name).unwrap_or_default()
)
}
}
impl FromStr for JsFile {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
if s.is_empty() {
Ok(JsFile::default())
} else {
bail!("Building file from non-empty string")
}
}
}
fn to_base64(file: &JsFile) -> Result<String> {
match file {
JsFile {
file: None,
contents: None,
} => Ok(String::new()),
JsFile {
file: Some(_),
contents: None,
} => bail!("Image file hasn't finished loading, try again"),
JsFile {
file: Some(_),
contents: Some(data),
} => {
if !is_valid_jpeg(data.as_slice()) {
bail!("Chosen image is not a valid JPEG");
}
Ok(base64::encode(data))
}
JsFile {
file: None,
contents: Some(data),
} => Ok(base64::encode(data)),
}
}
/// A [yew::Component] to display the user details, with a form allowing to edit them.
pub struct JpegFileInput {
// None means that the avatar hasn't changed.
avatar: Option<JsFile>,
reader: Option<FileReader>,
}
pub enum Msg {
Update,
/// A new file was selected.
FileSelected(File),
/// The "Clear" button for the avatar was clicked.
ClearClicked,
/// A picked file finished loading.
FileLoaded(String, Result<Vec<u8>>),
}
#[derive(Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub name: AttrValue,
pub value: Option<String>,
}
impl Component for JpegFileInput {
type Message = Msg;
type Properties = Props;
fn create(ctx: &Context<Self>) -> Self {
Self {
avatar: Some(JsFile {
file: None,
contents: ctx
.props()
.value
.as_ref()
.and_then(|x| base64::decode(x).ok()),
}),
reader: None,
}
}
fn changed(&mut self, ctx: &Context<Self>) -> bool {
self.avatar = Some(JsFile {
file: None,
contents: ctx
.props()
.value
.as_ref()
.and_then(|x| base64::decode(x).ok()),
});
self.reader = None;
true
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Update => true,
Msg::FileSelected(new_avatar) => {
if self
.avatar
.as_ref()
.and_then(|f| f.file.as_ref().map(|f| f.name()))
!= Some(new_avatar.name())
{
let file_name = new_avatar.name();
let link = ctx.link().clone();
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
link.send_message(Msg::FileLoaded(
file_name,
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
))
}));
self.avatar = Some(JsFile {
file: Some(new_avatar),
contents: None,
});
}
true
}
Msg::ClearClicked => {
self.avatar = Some(JsFile::default());
true
}
Msg::FileLoaded(file_name, data) => {
if let Some(avatar) = &mut self.avatar {
if let Some(file) = &avatar.file {
if file.name() == file_name {
if let Result::Ok(data) = data {
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = Some(JsFile::default());
// TODO: bail!("Chosen image is not a valid JPEG");
} else {
avatar.contents = Some(data);
return true;
}
}
}
}
}
self.reader = None;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
let avatar_string = match &self.avatar {
Some(avatar) => {
let avatar_base64 = to_base64(avatar);
avatar_base64.as_deref().unwrap_or("").to_owned()
}
None => String::new(),
};
html! {
<div class="row align-items-center">
<div class="col-5">
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Self::upload_files(input.files())
})} />
</div>
<div class="col-3">
<button
class="btn btn-secondary col-auto"
id="avatarClear"
type="button"
onclick={link.callback(|_| {Msg::ClearClicked})}>
{"Clear"}
</button>
</div>
<div class="col-4">
{
if !avatar_string.is_empty() {
html!{
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", avatar_string)}
style="max-height:128px;max-width:128px;height:auto;width:auto;"
alt="Avatar" />
}
} else { html! {} }
}
</div>
</div>
}
}
}
impl JpegFileInput {
fn upload_files(files: Option<FileList>) -> Msg {
match files {
Some(files) if files.length() > 0 => {
Msg::FileSelected(File::from(files.item(0).unwrap()))
}
Some(_) | None => Msg::Update,
}
}
}
fn is_valid_jpeg(bytes: &[u8]) -> bool {
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()
.is_ok()
}
+8
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;
+46
View File
@@ -0,0 +1,46 @@
use yew::{
Callback, Children, InputEvent, Properties, function_component, html, virtual_dom::AttrValue,
};
use yew_form::{Form, Model};
#[derive(Properties, PartialEq)]
pub struct Props<T: Model> {
pub label: AttrValue,
pub field_name: String,
pub form: Form<T>,
#[prop_or(false)]
pub required: bool,
#[prop_or_else(Callback::noop)]
pub oninput: Callback<InputEvent>,
pub children: Children,
}
#[function_component(Select)]
pub fn select<T: Model>(props: &Props<T>) -> Html {
html! {
<div class="row mb-3">
<label for={props.field_name.clone()}
class="form-label col-4 col-form-label">
{&props.label}
{if props.required {
html!{<span class="text-danger">{"*"}</span>}
} else {html!{}}}
{":"}
</label>
<div class="col-8">
<yew_form::Select<T>
form={&props.form}
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
field_name={props.field_name.clone()}
oninput={&props.oninput} >
{for props.children.iter()}
</yew_form::Select<T>>
<div class="invalid-feedback">
{&props.form.field_message(&props.field_name)}
</div>
</div>
</div>
}
}
+26
View File
@@ -0,0 +1,26 @@
use yew::{Children, Properties, function_component, html, virtual_dom::AttrValue};
#[derive(Properties, PartialEq)]
pub struct Props {
pub label: AttrValue,
pub id: AttrValue,
pub children: Children,
}
#[function_component(StaticValue)]
pub fn static_value(props: &Props) -> Html {
html! {
<div class="row mb-3">
<label for={props.id.clone()}
class="form-label col-4 col-form-label">
{&props.label}
{":"}
</label>
<div class="col-8">
<span id={props.id.clone()} class="form-control-static">
{for props.children.iter()}
</span>
</div>
</div>
}
}
+30
View File
@@ -0,0 +1,30 @@
use web_sys::MouseEvent;
use yew::{Callback, Children, Properties, function_component, html, virtual_dom::AttrValue};
#[derive(Properties, PartialEq)]
pub struct Props {
pub disabled: bool,
pub onclick: Callback<MouseEvent>,
// Additional elements to insert after the button, in the same div
#[prop_or_default]
pub children: Children,
#[prop_or(AttrValue::from("Submit"))]
pub text: AttrValue,
}
#[function_component(Submit)]
pub fn submit(props: &Props) -> Html {
html! {
<div class="form-group row justify-content-center">
<button
class="btn btn-primary col-auto col-form-label"
type="submit"
disabled={props.disabled}
onclick={&props.onclick}>
<i class="bi-save me-2"></i>
{props.text.clone()}
</button>
{for props.children.iter()}
</div>
}
}
@@ -0,0 +1,52 @@
use crate::infra::attributes::AttributeDescription;
use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name};
use yew::{Html, html};
fn render_attribute_aliases(attribute_description: &AttributeDescription) -> Html {
if attribute_description.aliases.is_empty() {
html! {}
} else {
html! {
<>
<br/>
<small class="text-muted">
{"Aliases: "}
{attribute_description.aliases.join(", ")}
</small>
</>
}
}
}
fn render_attribute_validation_warnings(attribute_name: &str) -> Html {
match validate_attribute_name(attribute_name) {
Ok(()) => {
html! {}
}
Err(_invalid_chars) => {
html! {
<>
<br/>
<small class="text-warning">
{"Warning: This attribute uses one or more invalid characters "}
{"("}{ALLOWED_CHARACTERS_DESCRIPTION}{"). "}
{"Some clients may not support it."}
</small>
</>
}
}
}
}
pub fn render_attribute_name(
hardcoded: bool,
attribute_description: &AttributeDescription,
) -> Html {
html! {
<>
{&attribute_description.attribute_name}
{if hardcoded {render_attribute_aliases(attribute_description)} else {html!{}}}
{render_attribute_validation_warnings(attribute_description.attribute_name)}
</>
}
}
+1
View File
@@ -0,0 +1 @@
pub mod attribute_schema;
+76 -72
View File
@@ -1,12 +1,17 @@
use crate::{
components::{
add_group_member::{self, AddGroupMemberComponent},
group_details_form::GroupDetailsForm,
remove_user_from_group::RemoveUserFromGroupComponent,
router::{AppRoute, Link},
},
infra::common_component::{CommonComponent, CommonComponentParts},
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::GraphQlAttributeSchema,
schema::AttributeType,
},
};
use anyhow::{bail, Error, Result};
use anyhow::{Error, Result, bail};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
@@ -15,19 +20,33 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_group_details.graphql",
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetGroupDetails;
pub type Group = get_group_details::GetGroupDetailsGroup;
pub type User = get_group_details::GetGroupDetailsGroupUsers;
pub type AddGroupMemberUser = add_group_member::User;
pub type Attribute = get_group_details::GetGroupDetailsGroupAttributes;
pub type AttributeSchema = get_group_details::GetGroupDetailsSchemaGroupSchemaAttributes;
impl From<&AttributeSchema> for GraphQlAttributeSchema {
fn from(attr: &AttributeSchema) -> Self {
Self {
name: attr.name.clone(),
is_list: attr.is_list,
is_readonly: attr.is_readonly,
is_editable: attr.is_editable,
}
}
}
pub struct GroupDetails {
common: CommonComponentParts<Self>,
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
/// received the server response yet.
group: Option<Group>,
group_and_schema: Option<(Group, Vec<AttributeSchema>)>,
}
/// State machine describing the possible transitions of the component state.
@@ -38,18 +57,21 @@ 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 {
fn get_group_details(&mut self) {
fn get_group_details(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<GetGroupDetails, _>(
ctx,
get_group_details::Variables {
id: self.common.group_id,
id: ctx.props().group_id,
},
Msg::GroupDetailsResponse,
"Error trying to fetch group details",
@@ -68,63 +90,39 @@ 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.date().naive_local()}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="uuid"
class="form-label col-4 col-form-label">
{"UUID: "}
</label>
<div class="col-8">
<span id="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)}
/>
</>
}
}
fn view_user_list(&self, g: &Group) -> Html {
fn view_user_list(&self, ctx: &Context<Self>, g: &Group) -> Html {
let link = ctx.link();
let make_user_row = |user: &User| {
let user_id = user.id.clone();
let display_name = user.display_name.clone();
html! {
<tr>
<td>
<Link route=AppRoute::UserDetails(user_id.clone())>
<Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
{user_id.clone()}
</Link>
</td>
<td>{display_name}</td>
<td>
<RemoveUserFromGroupComponent
username=user_id
group_id=g.id
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
on_error=self.common.callback(Msg::OnError)/>
username={user_id}
group_id={g.id}
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
on_error={link.callback(Msg::OnError)}/>
</td>
</tr>
}
@@ -133,7 +131,7 @@ impl GroupDetails {
<>
<h5 class="fw-bold">{"Members"}</h5>
<div class="table-responsive">
<table class="table table-striped">
<table class="table table-hover">
<thead>
<tr key="headerRow">
<th>{"User Id"}</th>
@@ -145,7 +143,7 @@ impl GroupDetails {
{if g.users.is_empty() {
html! {
<tr key="EmptyRow">
<td>{"No members"}</td>
<td>{"There are no users in this group."}</td>
<td/>
</tr>
}
@@ -159,7 +157,8 @@ impl GroupDetails {
}
}
fn view_add_user_button(&self, g: &Group) -> Html {
fn view_add_user_button(&self, ctx: &Context<Self>, g: &Group) -> Html {
let link = ctx.link();
let users: Vec<_> = g
.users
.iter()
@@ -170,38 +169,47 @@ impl GroupDetails {
.collect();
html! {
<AddGroupMemberComponent
group_id=g.id
users=users
on_error=self.common.callback(Msg::OnError)
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
group_id={g.id}
users={users}
on_error={link.callback(Msg::OnError)}
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
}
}
}
impl CommonComponent<GroupDetails> for GroupDetails {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::GroupDetailsResponse(response) => match response {
Ok(group) => self.group = Some(group.group),
Ok(group) => {
self.group_and_schema =
Some((group.group, group.schema.group_schema.attributes))
}
Err(e) => {
self.group = None;
self.group_and_schema = None;
bail!("Error getting user details: {}", e);
}
},
Msg::OnError(e) => return Err(e),
Msg::OnUserAddedToGroup(user) => {
self.group.as_mut().unwrap().users.push(User {
self.group_and_schema.as_mut().unwrap().0.users.push(User {
id: user.id,
display_name: user.display_name,
});
}
Msg::OnUserRemovedFromGroup((user_id, _)) => {
self.group
self.group_and_schema
.as_mut()
.unwrap()
.0
.users
.retain(|u| u.id != user_id);
}
Msg::DisplayNameUpdated => self.get_group_details(ctx),
}
Ok(true)
}
@@ -215,33 +223,29 @@ impl Component for GroupDetails {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut table = Self {
common: CommonComponentParts::<Self>::create(props, link),
group: None,
common: CommonComponentParts::<Self>::create(),
group_and_schema: None,
};
table.get_group_details();
table.get_group_details(ctx);
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
match (&self.group, &self.common.error) {
fn view(&self, ctx: &Context<Self>) -> Html {
match (&self.group_and_schema, &self.common.error) {
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
(Some(u), error) => {
(Some((group, schema)), error) => {
html! {
<div>
{self.view_details(u)}
{self.view_user_list(u)}
{self.view_add_user_button(u)}
{self.view_details(ctx, group, schema.clone())}
{self.view_user_list(ctx, group)}
{self.view_add_user_button(ctx, group)}
{self.view_messages(error)}
</div>
}
+271
View File
@@ -0,0 +1,271 @@
use crate::{
components::{
form::{
attribute_input::{ListAttributeInput, SingleAttributeInput},
static_value::StaticValue,
submit::Submit,
},
group_details::{Attribute, AttributeSchema, Group},
},
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
},
};
use anyhow::{Ok, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
/// The GraphQL query sent to the server to update the group details.
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/update_group.graphql",
response_derives = "Debug",
variables_derives = "Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct UpdateGroup;
/// A [yew::Component] to display the group details, with a form allowing to edit them.
pub struct GroupDetailsForm {
common: CommonComponentParts<Self>,
/// True if we just successfully updated the group, to display a success message.
just_updated: bool,
updated_group_name: bool,
group: Group,
form_ref: NodeRef,
}
pub enum Msg {
/// A form field changed.
Update,
/// The "Submit" button was clicked.
SubmitClicked,
/// We got the response from the server about our update message.
GroupUpdated(Result<update_group::ResponseData>),
}
#[derive(yew::Properties, Clone, PartialEq)]
pub struct Props {
/// The current group details.
pub group: Group,
pub group_attributes_schema: Vec<AttributeSchema>,
pub is_admin: bool,
pub on_display_name_updated: Callback<()>,
}
impl CommonComponent<GroupDetailsForm> for GroupDetailsForm {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitClicked => self.submit_group_update_form(ctx),
Msg::GroupUpdated(Err(e)) => Err(e),
Msg::GroupUpdated(Result::Ok(_)) => {
self.just_updated = true;
if self.updated_group_name {
self.updated_group_name = false;
ctx.props().on_display_name_updated.emit(());
}
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for GroupDetailsForm {
type Message = Msg;
type Properties = Props;
fn create(ctx: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(),
just_updated: false,
updated_group_name: false,
group: ctx.props().group.clone(),
form_ref: NodeRef::default(),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
self.just_updated = false;
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
let can_edit =
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
let display_field = |a: &AttributeSchema| {
if can_edit(a) {
get_custom_attribute_input(a, &self.group.attributes)
} else {
get_custom_attribute_static(a, &self.group.attributes)
}
};
html! {
<div class="py-3">
<form
class="form"
ref={self.form_ref.clone()}>
<StaticValue label="Group ID" id="groupId">
<i>{&self.group.id}</i>
</StaticValue>
{
ctx
.props()
.group_attributes_schema
.iter()
.filter(|a| a.is_hardcoded && a.name != "group_id")
.map(display_field)
.collect::<Vec<_>>()
}
{
ctx
.props()
.group_attributes_schema
.iter()
.filter(|a| !a.is_hardcoded)
.map(display_field)
.collect::<Vec<_>>()
}
<Submit
text="Save changes"
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})} />
</form>
{
if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
<div hidden={!self.just_updated}>
<div class="alert alert-success mt-4">{"Group successfully updated!"}</div>
</div>
</div>
}
}
}
fn get_custom_attribute_input(
attribute_schema: &AttributeSchema,
group_attributes: &[Attribute],
) -> Html {
let values = group_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
if attribute_schema.is_list {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
values={values}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
value={values.first().cloned().unwrap_or_default()}
/>
}
}
}
fn get_custom_attribute_static(
attribute_schema: &AttributeSchema,
group_attributes: &[Attribute],
) -> Html {
let values = group_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
html! {
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
</StaticValue>
}
}
impl GroupDetailsForm {
fn submit_group_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
let mut all_values = read_all_form_attributes(
ctx.props().group_attributes_schema.iter(),
&self.form_ref,
IsAdmin(ctx.props().is_admin),
EmailIsRequired(false),
)?;
let base_attributes = &self.group.attributes;
all_values.retain(|a| {
let base_val = base_attributes
.iter()
.find(|base_val| base_val.name == a.name);
base_val
.map(|v| v.value != a.values)
.unwrap_or(!a.values.is_empty())
});
if all_values.iter().any(|a| a.name == "display_name") {
self.updated_group_name = true;
}
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
None
} else {
Some(all_values.iter().map(|a| a.name.clone()).collect())
};
let insert_attributes: Option<Vec<update_group::AttributeValueInput>> =
if remove_attributes.is_none() {
None
} else {
Some(
all_values
.into_iter()
.filter(|a| !a.values.is_empty())
.map(
|AttributeValue { name, values }| update_group::AttributeValueInput {
name,
value: values,
},
)
.collect(),
)
};
let mut group_input = update_group::UpdateGroupInput {
id: self.group.id,
displayName: None,
removeAttributes: None,
insertAttributes: None,
};
let default_group_input = group_input.clone();
group_input.removeAttributes = remove_attributes;
group_input.insertAttributes = insert_attributes;
// Nothing changed.
if group_input == default_group_input {
return Ok(false);
}
let req = update_group::Variables { group: group_input };
self.common.call_graphql::<UpdateGroup, _>(
ctx,
req,
Msg::GroupUpdated,
"Error trying to update group",
);
Ok(false)
}
}
+199
View File
@@ -0,0 +1,199 @@
use crate::{
components::{
delete_group_attribute::DeleteGroupAttribute,
fragments::attribute_schema::render_attribute_name,
router::{AppRoute, Link},
},
infra::{
attributes::group,
common_component::{CommonComponent, CommonComponentParts},
schema::AttributeType,
},
};
use anyhow::{Error, Result, anyhow};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_group_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetGroupAttributesSchema;
use get_group_attributes_schema::ResponseData;
pub type Attribute =
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub hardcoded: bool,
}
pub struct GroupSchemaTable {
common: CommonComponentParts<Self>,
attributes: Option<Vec<Attribute>>,
}
pub enum Msg {
ListAttributesResponse(Result<ResponseData>),
OnAttributeDeleted(String),
OnError(Error),
}
impl CommonComponent<GroupSchemaTable> for GroupSchemaTable {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListAttributesResponse(schema) => {
self.attributes =
Some(schema?.schema.group_schema.attributes.into_iter().collect());
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnAttributeDeleted(attribute_name) => match self.attributes {
None => {
log!(format!(
"Attribute {attribute_name} was deleted but component has no attributes"
));
Err(anyhow!("invalid state"))
}
Some(_) => {
self.attributes
.as_mut()
.unwrap()
.retain(|a| a.name != attribute_name);
Ok(true)
}
},
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for GroupSchemaTable {
type Message = Msg;
type Properties = Props;
fn create(ctx: &Context<Self>) -> Self {
let mut table = GroupSchemaTable {
common: CommonComponentParts::<Self>::create(),
attributes: None,
};
table.common.call_graphql::<GetGroupAttributesSchema, _>(
ctx,
get_group_attributes_schema::Variables {},
Msg::ListAttributesResponse,
"Error trying to fetch group schema",
);
table
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div>
{self.view_attributes(ctx)}
{self.view_errors()}
</div>
}
}
}
impl GroupSchemaTable {
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
let hardcoded = ctx.props().hardcoded;
let make_table = |attributes: &Vec<Attribute>| {
html! {
<div class="table-responsive">
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
<table class="table table-hover">
<thead>
<tr>
<th>{"Attribute name"}</th>
<th>{"Type"}</th>
<th>{"Visible"}</th>
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
</tr>
</thead>
<tbody>
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
</tbody>
</table>
</div>
}
};
match &self.attributes {
None => html! {{"Loading..."}},
Some(attributes) => {
let mut attributes = attributes.clone();
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
make_table(&attributes)
}
}
}
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
let link = ctx.link();
let attribute_type = attribute.attribute_type;
let checkmark = html! {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
</svg>
};
let hardcoded = ctx.props().hardcoded;
let desc = group::resolve_group_attribute_description_or_default(&attribute.name);
html! {
<tr key={attribute.name.clone()}>
<td>{render_attribute_name(hardcoded, &desc)}</td>
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
{
if hardcoded {
html!{}
} else {
html!{
<td>
<DeleteGroupAttribute
attribute_name={attribute.name.clone()}
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
on_error={link.callback(Msg::OnError)}/>
</td>
}
}
}
</tr>
}
}
fn view_errors(&self) -> Html {
match &self.common.error {
None => html! {},
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
}
}
}
#[function_component(ListGroupSchema)]
pub fn list_group_schema() -> Html {
html! {
<div>
<GroupSchemaTable hardcoded={true} />
<GroupSchemaTable hardcoded={false} />
<Link classes="btn btn-primary" to={AppRoute::CreateGroupAttribute}>
<i class="bi-plus-circle me-2"></i>
{"Create an attribute"}
</Link>
</div>
}
}
+19 -21
View File
@@ -34,7 +34,7 @@ pub enum Msg {
}
impl CommonComponent<GroupTable> for GroupTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListGroupsResponse(groups) => {
self.groups = Some(groups?.groups.into_iter().collect());
@@ -58,12 +58,13 @@ impl Component for GroupTable {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut table = GroupTable {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
groups: None,
};
table.common.call_graphql::<GetGroupList, _>(
ctx,
get_group_list::Variables {},
Msg::ListGroupsResponse,
"Error trying to fetch groups",
@@ -71,18 +72,14 @@ impl Component for GroupTable {
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div>
{self.view_groups()}
{self.view_groups(ctx)}
{self.view_errors()}
</div>
}
@@ -90,11 +87,11 @@ impl Component for GroupTable {
}
impl GroupTable {
fn view_groups(&self) -> Html {
fn view_groups(&self, ctx: &Context<Self>) -> Html {
let make_table = |groups: &Vec<Group>| {
html! {
<div class="table-responsive">
<table class="table table-striped">
<table class="table table-hover">
<thead>
<tr>
<th>{"Group name"}</th>
@@ -103,7 +100,7 @@ impl GroupTable {
</tr>
</thead>
<tbody>
{groups.iter().map(|u| self.view_group(u)).collect::<Vec<_>>()}
{groups.iter().map(|u| self.view_group(ctx, u)).collect::<Vec<_>>()}
</tbody>
</table>
</div>
@@ -115,22 +112,23 @@ impl GroupTable {
}
}
fn view_group(&self, group: &Group) -> Html {
fn view_group(&self, ctx: &Context<Self>, group: &Group) -> Html {
let link = ctx.link();
html! {
<tr key=group.id>
<tr key={group.id}>
<td>
<Link route=AppRoute::GroupDetails(group.id)>
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
{&group.display_name}
</Link>
</td>
<td>
{&group.creation_date.date().naive_local()}
{&group.creation_date.naive_local().date()}
</td>
<td>
<DeleteGroup
group=group.clone()
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
on_error=self.common.callback(Msg::OnError)/>
group={group.clone()}
on_group_deleted={link.callback(Msg::OnGroupDeleted)}
on_error={link.callback(Msg::OnError)}/>
</td>
</tr>
}
+89 -85
View File
@@ -1,14 +1,18 @@
use crate::{
components::router::{AppRoute, NavButton},
components::{
form::submit::Submit,
router::{AppRoute, Link},
},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{anyhow, bail, Context, Result};
use anyhow::{Result, anyhow, bail};
use gloo_console::error;
use lldap_auth::*;
use validator_derive::Validate;
use yew::{prelude::*, services::ConsoleService};
use yew::prelude::*;
use yew_form::Form;
use yew_form_derive::Model;
@@ -30,6 +34,7 @@ pub struct FormModel {
#[derive(Clone, PartialEq, Properties)]
pub struct Props {
pub on_logged_in: Callback<(String, bool)>,
pub password_reset_enabled: bool,
}
pub enum Msg {
@@ -46,7 +51,12 @@ pub enum Msg {
}
impl CommonComponent<LoginForm> for LoginForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
use anyhow::Context;
match msg {
Msg::Update => Ok(true),
Msg::Submit => {
@@ -59,13 +69,13 @@ impl CommonComponent<LoginForm> for LoginForm {
opaque::client::login::start_login(&password, &mut rng)
.context("Could not initialize login")?;
let req = login::ClientLoginStartRequest {
username,
username: username.into(),
login_start_request: message,
};
self.common
.call_backend(HostService::login_start, req, move |r| {
.call_backend(ctx, HostService::login_start(req), move |r| {
Msg::AuthenticationStartResponse((state, r))
})?;
});
Ok(true)
}
Msg::AuthenticationStartResponse((login_start, res)) => {
@@ -76,9 +86,8 @@ impl CommonComponent<LoginForm> for LoginForm {
Err(e) => {
// Common error, we want to print a full error to the console but only a
// simple one to the user.
ConsoleService::error(&format!("Invalid username or password: {}", e));
error!(&format!("Invalid username or password: {}", e));
self.common.error = Some(anyhow!("Invalid username or password"));
self.common.cancel_task();
return Ok(true);
}
Ok(l) => l,
@@ -88,24 +97,22 @@ impl CommonComponent<LoginForm> for LoginForm {
credential_finalization: login_finish.message,
};
self.common.call_backend(
HostService::login_finish,
req,
ctx,
HostService::login_finish(req),
Msg::AuthenticationFinishResponse,
)?;
);
Ok(false)
}
Msg::AuthenticationFinishResponse(user_info) => {
self.common.cancel_task();
self.common
ctx.props()
.on_logged_in
.emit(user_info.context("Could not log in")?);
Ok(true)
}
Msg::AuthenticationRefreshResponse(user_info) => {
self.refreshing = false;
self.common.cancel_task();
if let Ok(user_info) = user_info {
self.common.on_logged_in.emit(user_info);
ctx.props().on_logged_in.emit(user_info);
}
Ok(true)
}
@@ -121,32 +128,28 @@ impl Component for LoginForm {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut app = LoginForm {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
form: Form::<FormModel>::new(FormModel::default()),
refreshing: true,
};
if let Err(e) =
app.common
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse)
{
ConsoleService::debug(&format!("Could not refresh auth: {}", e));
app.refreshing = false;
}
app.common.call_backend(
ctx,
HostService::refresh(),
Msg::AuthenticationRefreshResponse,
);
app
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
type Field = yew_form::Field<FormModel>;
let password_reset_enabled = ctx.props().password_reset_enabled;
let link = &ctx.link();
if self.refreshing {
html! {
<div>
@@ -155,61 +158,62 @@ impl Component for LoginForm {
}
} else {
html! {
<form
class="form center-block col-sm-4 col-offset-4">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-person-fill"/>
</span>
</div>
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="username"
placeholder="Username"
autocomplete="username"
oninput=self.common.callback(|_| Msg::Update) />
<form class="form center-block col-sm-4 col-offset-4">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-person-fill"/>
</span>
</div>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-lock-fill"/>
</span>
</div>
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="password"
input_type="password"
placeholder="Password"
autocomplete="current-password" />
</div>
<div class="form-group mt-3">
<button
type="submit"
class="btn btn-primary"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Login"}
</button>
<NavButton
classes="btn-link btn"
disabled=self.common.is_task_running()
route=AppRoute::StartResetPassword>
{"Forgot your password?"}
</NavButton>
</div>
<div class="form-group">
{ if let Some(e) = &self.common.error {
html! { e.to_string() }
} else { html! {} }
}
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form={&self.form}
field_name="username"
placeholder="Username"
autocomplete="username"
oninput={link.callback(|_| Msg::Update)} />
</div>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-lock-fill"/>
</span>
</div>
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form={&self.form}
field_name="password"
input_type="password"
placeholder="Password"
autocomplete="current-password" />
</div>
<Submit
text="Login"
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
{ if password_reset_enabled {
html! {
<Link
classes="btn-link btn"
disabled={self.common.is_task_running()}
to={AppRoute::StartResetPassword}>
{"Forgot your password?"}
</Link>
}
} else {
html!{}
}}
</Submit>
<div class="form-group">
{ if let Some(e) = &self.common.error {
html! { e.to_string() }
} else { html! {} }
}
</div>
</form>
}
}
+14 -13
View File
@@ -21,16 +21,20 @@ pub enum Msg {
}
impl CommonComponent<LogoutButton> for LogoutButton {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::LogoutRequested => {
self.common
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
.call_backend(ctx, HostService::logout(), Msg::LogoutCompleted);
}
Msg::LogoutCompleted(res) => {
res?;
delete_cookie("user_id")?;
self.common.on_logged_out.emit(());
ctx.props().on_logged_out.emit(());
}
}
Ok(false)
@@ -45,25 +49,22 @@ impl Component for LogoutButton {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
LogoutButton {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<button
class="dropdown-item"
onclick=self.common.callback(|_| Msg::LogoutRequested)>
onclick={link.callback(|_| Msg::LogoutRequested)}>
{"Logout"}
</button>
}
+11
View File
@@ -1,12 +1,22 @@
pub mod add_group_member;
pub mod add_user_to_group;
pub mod app;
pub mod avatar;
pub mod banner;
pub mod change_password;
pub mod create_group;
pub mod create_group_attribute;
pub mod create_user;
pub mod create_user_attribute;
pub mod delete_group;
pub mod delete_group_attribute;
pub mod delete_user;
pub mod delete_user_attribute;
pub mod form;
pub mod fragments;
pub mod group_details;
pub mod group_details_form;
pub mod group_schema_table;
pub mod group_table;
pub mod login;
pub mod logout;
@@ -17,4 +27,5 @@ pub mod router;
pub mod select;
pub mod user_details;
pub mod user_details_form;
pub mod user_schema_table;
pub mod user_table;
+21 -19
View File
@@ -31,15 +31,18 @@ pub enum Msg {
}
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::SubmitRemoveGroup => self.submit_remove_group(),
Msg::SubmitRemoveGroup => self.submit_remove_group(ctx),
Msg::RemoveGroupResponse(response) => {
response?;
self.common.cancel_task();
self.common
ctx.props()
.on_user_removed_from_group
.emit((self.common.username.clone(), self.common.group_id));
.emit((ctx.props().username.clone(), ctx.props().group_id));
}
}
Ok(true)
@@ -51,11 +54,12 @@ impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupCompon
}
impl RemoveUserFromGroupComponent {
fn submit_remove_group(&mut self) {
fn submit_remove_group(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<RemoveUserFromGroup, _>(
ctx,
remove_user_from_group::Variables {
user: self.common.username.clone(),
group: self.common.group_id,
user: ctx.props().username.clone(),
group: ctx.props().group_id,
},
Msg::RemoveGroupResponse,
"Error trying to initiate removing the user from a group",
@@ -67,30 +71,28 @@ impl Component for RemoveUserFromGroupComponent {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error(
self,
ctx,
msg,
self.common.on_error.clone(),
ctx.props().on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! {
<button
class="btn btn-danger"
disabled=self.common.is_task_running()
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
disabled={self.common.is_task_running()}
onclick={link.callback(|_| Msg::SubmitRemoveGroup)}>
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
</button>
}
+32 -26
View File
@@ -1,11 +1,11 @@
use crate::{
components::router::{AppRoute, NavButton},
components::router::{AppRoute, Link},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use validator_derive::Validate;
use yew::prelude::*;
use yew_form::Form;
@@ -31,7 +31,11 @@ pub enum Msg {
}
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::Submit => {
@@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
}
let FormModel { username } = self.form.model();
self.common.call_backend(
HostService::reset_password_step1,
&username,
ctx,
HostService::reset_password_step1(username),
Msg::PasswordResetResponse,
)?;
);
Ok(true)
}
Msg::PasswordResetResponse(response) => {
@@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
ResetPasswordStep1Form {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
form: Form::<FormModel>::new(FormModel::default()),
just_succeeded: false,
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
self.just_succeeded = false;
CommonComponentParts::<Self>::update(self, msg)
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
type Field = yew_form::Field<FormModel>;
let link = &ctx.link();
html! {
<form
class="form center-block col-sm-4 col-offset-4">
@@ -95,15 +96,19 @@ impl Component for ResetPasswordStep1Form {
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
form={&self.form}
field_name="username"
placeholder="Username"
placeholder="Username or email"
autocomplete="username"
oninput=self.common.callback(|_| Msg::Update) />
oninput={link.callback(|_| Msg::Update)} />
</div>
{ if self.just_succeeded {
html! {
{"A reset token has been sent to your email."}
{"If a user with this username or email exists, a password reset email will \
be sent to the associated email address. Please check your email and \
follow the instructions. If you don't receive an email, please check \
your spam folder. If you still don't receive an email, please contact \
your administrator."}
}
} else {
html! {
@@ -111,23 +116,24 @@ impl Component for ResetPasswordStep1Form {
<button
type="submit"
class="btn btn-primary"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
<i class="bi-check-circle me-2"/>
{"Reset password"}
</button>
<NavButton
<Link
classes="btn-link btn"
disabled=self.common.is_task_running()
route=AppRoute::Login>
disabled={self.common.is_task_running()}
to={AppRoute::Login}>
{"Back"}
</NavButton>
</Link>
</div>
}
}}
<div class="form-group">
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
<div class="alert alert-danger mb-2">
{e.to_string() }
</div>
}
+66 -96
View File
@@ -1,11 +1,14 @@
use crate::{
components::router::AppRoute,
components::{
form::{field::Field, submit::Submit},
router::{AppRoute, Link},
},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Context, Result};
use anyhow::{Result, bail};
use lldap_auth::{
opaque::client::registration as opaque_registration,
password_reset::ServerPasswordResetResponse, registration,
@@ -14,10 +17,7 @@ use validator_derive::Validate;
use yew::prelude::*;
use yew_form::Form;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
/// The fields of the form, with the constraints.
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
@@ -33,7 +33,6 @@ pub struct ResetPasswordStep2Form {
form: Form<FormModel>,
username: Option<String>,
opaque_data: Option<opaque_registration::ClientRegistration>,
route_dispatcher: RouteAgentDispatcher,
}
#[derive(Clone, PartialEq, Eq, Properties)]
@@ -50,11 +49,15 @@ pub enum Msg {
}
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
use anyhow::Context;
match msg {
Msg::ValidateTokenResponse(response) => {
self.username = Some(response?.user_id);
self.common.cancel_task();
Ok(true)
}
Msg::FormUpdate => Ok(true),
@@ -65,18 +68,18 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
let mut rng = rand::rngs::OsRng;
let new_password = self.form.model().password;
let registration_start_request =
opaque_registration::start_registration(&new_password, &mut rng)
opaque_registration::start_registration(new_password.as_bytes(), &mut rng)
.context("Could not initiate password change")?;
let req = registration::ClientRegistrationStartRequest {
username: self.username.clone().unwrap(),
username: self.username.as_ref().unwrap().into(),
registration_start_request: registration_start_request.message,
};
self.opaque_data = Some(registration_start_request.state);
self.common.call_backend(
HostService::register_start,
req,
ctx,
HostService::register_start(req),
Msg::RegistrationStartResponse,
)?;
);
Ok(true)
}
Msg::RegistrationStartResponse(res) => {
@@ -94,17 +97,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
registration_upload: registration_finish.message,
};
self.common.call_backend(
HostService::register_finish,
req,
ctx,
HostService::register_finish(req),
Msg::RegistrationFinishResponse,
)?;
);
Ok(false)
}
Msg::RegistrationFinishResponse(response) => {
self.common.cancel_task();
if response.is_ok() {
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
ctx.link().history().unwrap().push(AppRoute::Login);
}
response?;
Ok(true)
@@ -121,105 +122,74 @@ impl Component for ResetPasswordStep2Form {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut component = ResetPasswordStep2Form {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<FormModel>::new(FormModel::default()),
opaque_data: None,
route_dispatcher: RouteAgentDispatcher::new(),
username: None,
};
let token = component.common.token.clone();
component
.common
.call_backend(
HostService::reset_password_step2,
&token,
Msg::ValidateTokenResponse,
)
.unwrap();
let token = ctx.props().token.clone();
component.common.call_backend(
ctx,
HostService::reset_password_step2(token),
Msg::ValidateTokenResponse,
);
component
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
match (&self.username, &self.common.error) {
(None, None) => {
return html! {
{"Validating token"}
}
};
}
(None, Some(e)) => {
return html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
<>
<div class="alert alert-danger">
{e.to_string() }
</div>
<Link
classes="btn-link btn"
disabled={self.common.is_task_running()}
to={AppRoute::Login}>
{"Back"}
</Link>
</>
};
}
_ => (),
};
type Field = yew_form::Field<FormModel>;
html! {
<>
<h2>{"Reset your password"}</h2>
<form
class="form">
<div class="form-group row">
<label for="new_password"
class="form-label col-sm-2 col-form-label">
{"New password*:"}
</label>
<div class="col-sm-10">
<Field
form=&self.form
field_name="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
input_type="password"
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("password")}
</div>
</div>
</div>
<div class="form-group row">
<label for="confirm_password"
class="form-label col-sm-2 col-form-label">
{"Confirm password*:"}
</label>
<div class="col-sm-10">
<Field
form=&self.form
field_name="confirm_password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
input_type="password"
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("confirm_password")}
</div>
</div>
</div>
<div class="form-group row mt-2">
<button
class="btn btn-primary col-sm-1 col-form-label"
type="submit"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Submit"}
</button>
</div>
<form class="form">
<Field<FormModel>
label="New password"
required=true
form={&self.form}
field_name="password"
autocomplete="new-password"
input_type="password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<Field<FormModel>
label="Confirm password"
required=true
form={&self.form}
field_name="confirm_password"
autocomplete="new-password"
input_type="password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
</form>
{ if let Some(e) = &self.common.error {
html! {
+27 -23
View File
@@ -1,34 +1,38 @@
use yew_router::{
components::{RouterAnchor, RouterButton},
Switch,
};
use yew_router::Routable;
#[derive(Switch, Debug, Clone)]
#[derive(Routable, Debug, Clone, PartialEq)]
pub enum AppRoute {
#[to = "/login"]
#[at("/login")]
Login,
#[to = "/reset-password/step1"]
#[at("/reset-password/step1")]
StartResetPassword,
#[to = "/reset-password/step2/{token}"]
FinishResetPassword(String),
#[to = "/users/create"]
#[at("/reset-password/step2/:token")]
FinishResetPassword { token: String },
#[at("/users/create")]
CreateUser,
#[to = "/users"]
#[at("/users")]
ListUsers,
#[to = "/user/{user_id}/password"]
ChangePassword(String),
#[to = "/user/{user_id}"]
UserDetails(String),
#[to = "/groups/create"]
#[at("/user/:user_id/password")]
ChangePassword { user_id: String },
#[at("/user/:user_id")]
UserDetails { user_id: String },
#[at("/groups/create")]
CreateGroup,
#[to = "/groups"]
#[at("/groups")]
ListGroups,
#[to = "/group/{group_id}"]
GroupDetails(i64),
#[to = "/"]
#[at("/group/:group_id")]
GroupDetails { group_id: i64 },
#[at("/user-attributes")]
ListUserSchema,
#[at("/user-attributes/create")]
CreateUserAttribute,
#[at("/group-attributes")]
ListGroupSchema,
#[at("/group-attributes/create")]
CreateGroupAttribute,
#[at("/")]
Index,
}
pub type Link = RouterAnchor<AppRoute>;
pub type NavButton = RouterButton<AppRoute>;
pub type Link = yew_router::components::Link<AppRoute>;
pub type Redirect = yew_router::components::Redirect<AppRoute>;
+25 -58
View File
@@ -1,9 +1,6 @@
use yew::{html::ChangeData, prelude::*};
use yewtil::NeqAssign;
use yew::prelude::*;
pub struct Select {
link: ComponentLink<Self>,
props: SelectProps,
node_ref: NodeRef,
}
@@ -14,100 +11,70 @@ pub struct SelectProps {
}
pub enum SelectMsg {
OnSelectChange(ChangeData),
OnSelectChange,
}
impl Select {
fn get_nth_child_props(&self, nth: i32) -> Option<SelectOptionProps> {
fn get_nth_child_props(&self, ctx: &Context<Self>, nth: i32) -> Option<SelectOptionProps> {
if nth == -1 {
return None;
}
self.props
ctx.props()
.children
.iter()
.nth(nth as usize)
.map(|child| child.props)
.map(|child| (*child.props).clone())
}
fn send_selection_update(&self) {
fn send_selection_update(&self, ctx: &Context<Self>) {
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
self.props
ctx.props()
.on_selection_change
.emit(self.get_nth_child_props(select_node.selected_index()))
.emit(self.get_nth_child_props(ctx, select_node.selected_index()))
}
}
impl Component for Select {
type Message = SelectMsg;
type Properties = SelectProps;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
Self {
link,
props,
node_ref: NodeRef::default(),
}
}
fn rendered(&mut self, _first_render: bool) {
self.send_selection_update();
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
self.send_selection_update(ctx);
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
let SelectMsg::OnSelectChange(data) = msg;
match data {
ChangeData::Select(_) => self.send_selection_update(),
_ => unreachable!(),
}
fn update(&mut self, ctx: &Context<Self>, _: Self::Message) -> bool {
self.send_selection_update(ctx);
false
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.props.children.neq_assign(props.children)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<select
ref=self.node_ref.clone()
disabled=self.props.children.is_empty()
onchange=self.link.callback(SelectMsg::OnSelectChange)>
{ self.props.children.clone() }
<select class="form-select"
ref={self.node_ref.clone()}
disabled={ctx.props().children.is_empty()}
onchange={ctx.link().callback(|_| SelectMsg::OnSelectChange)}>
{ ctx.props().children.clone() }
</select>
}
}
}
pub struct SelectOption {
props: SelectOptionProps,
}
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
pub struct SelectOptionProps {
pub value: String,
pub text: String,
}
impl Component for SelectOption {
type Message = ();
type Properties = SelectOptionProps;
fn create(props: Self::Properties, _: ComponentLink<Self>) -> Self {
Self { props }
}
fn update(&mut self, _: Self::Message) -> ShouldRender {
false
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.props.neq_assign(props)
}
fn view(&self) -> Html {
html! {
<option value=self.props.value.clone()>
{&self.props.text}
</option>
}
#[function_component(SelectOption)]
pub fn select_option(props: &SelectOptionProps) -> Html {
html! {
<option value={props.value.clone()}>
{&props.text}
</option>
}
}
+87 -58
View File
@@ -2,12 +2,16 @@ use crate::{
components::{
add_user_to_group::AddUserToGroupComponent,
remove_user_from_group::RemoveUserFromGroupComponent,
router::{AppRoute, Link, NavButton},
router::{AppRoute, Link},
user_details_form::UserDetailsForm,
},
infra::common_component::{CommonComponent, CommonComponentParts},
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::GraphQlAttributeSchema,
schema::AttributeType,
},
};
use anyhow::{bail, Error, Result};
use anyhow::{Error, Result, bail};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
@@ -16,18 +20,38 @@ use yew::prelude::*;
schema_path = "../schema.graphql",
query_path = "queries/get_user_details.graphql",
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql"
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetUserDetails;
pub type User = get_user_details::GetUserDetailsUser;
pub type Group = get_user_details::GetUserDetailsUserGroups;
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
impl From<&AttributeSchema> for GraphQlAttributeSchema {
fn from(attr: &AttributeSchema) -> Self {
Self {
name: attr.name.clone(),
is_list: attr.is_list,
is_readonly: attr.is_readonly,
is_editable: attr.is_editable,
}
}
}
pub struct UserDetails {
common: CommonComponentParts<Self>,
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
/// received the server response yet.
user: Option<User>,
user_and_schema: Option<(User, Vec<AttributeSchema>)>,
}
impl UserDetails {
fn mut_groups(&mut self) -> &mut Vec<Group> {
&mut self.user_and_schema.as_mut().unwrap().0.groups
}
}
/// State machine describing the possible transitions of the component state.
@@ -47,25 +71,23 @@ pub struct Props {
}
impl CommonComponent<UserDetails> for UserDetails {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::UserDetailsResponse(response) => match response {
Ok(user) => self.user = Some(user.user),
Ok(user) => {
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
}
Err(e) => {
self.user = None;
self.user_and_schema = None;
bail!("Error getting user details: {}", e);
}
},
Msg::OnError(e) => return Err(e),
Msg::OnUserAddedToGroup(group) => {
self.user.as_mut().unwrap().groups.push(group);
self.mut_groups().push(group);
}
Msg::OnUserRemovedFromGroup((_, group_id)) => {
self.user
.as_mut()
.unwrap()
.groups
.retain(|g| g.id != group_id);
self.mut_groups().retain(|g| g.id != group_id);
}
}
Ok(true)
@@ -77,10 +99,11 @@ impl CommonComponent<UserDetails> for UserDetails {
}
impl UserDetails {
fn get_user_details(&mut self) {
fn get_user_details(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<GetUserDetails, _>(
ctx,
get_user_details::Variables {
id: self.common.username.clone(),
id: ctx.props().username.clone(),
},
Msg::UserDetailsResponse,
"Error trying to fetch user details",
@@ -99,24 +122,25 @@ impl UserDetails {
}
}
fn view_group_memberships(&self, u: &User) -> Html {
fn view_group_memberships(&self, ctx: &Context<Self>, u: &User) -> Html {
let link = &ctx.link();
let make_group_row = |group: &Group| {
let display_name = group.display_name.clone();
html! {
<tr key="groupRow_".to_string() + &display_name>
{if self.common.is_admin { html! {
<tr key={"groupRow_".to_string() + &display_name}>
{if ctx.props().is_admin { html! {
<>
<td>
<Link route=AppRoute::GroupDetails(group.id)>
<Link to={AppRoute::GroupDetails{group_id: group.id}}>
{&group.display_name}
</Link>
</td>
<td>
<RemoveUserFromGroupComponent
username=u.id.clone()
group_id=group.id
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
on_error=self.common.callback(Msg::OnError)/>
username={u.id.clone()}
group_id={group.id}
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
on_error={link.callback(Msg::OnError)}/>
</td>
</>
} } else { html! {
@@ -129,18 +153,18 @@ impl UserDetails {
<>
<h5 class="row m-3 fw-bold">{"Group memberships"}</h5>
<div class="table-responsive">
<table class="table table-striped">
<table class="table table-hover">
<thead>
<tr key="headerRow">
<th>{"Group"}</th>
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
{ if ctx.props().is_admin { html!{ <th></th> }} else { html!{} }}
</tr>
</thead>
<tbody>
{if u.groups.is_empty() {
html! {
<tr key="EmptyRow">
<td>{"Not member of any group"}</td>
<td>{"This user is not a member of any groups."}</td>
</tr>
}
} else {
@@ -153,14 +177,15 @@ impl UserDetails {
}
}
fn view_add_group_button(&self, u: &User) -> Html {
if self.common.is_admin {
fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
let link = &ctx.link();
if ctx.props().is_admin {
html! {
<AddUserToGroupComponent
username=u.id.clone()
groups=u.groups.clone()
on_error=self.common.callback(Msg::OnError)
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
username={u.id.clone()}
groups={u.groups.clone()}
on_error={link.callback(Msg::OnError)}
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
}
} else {
html! {}
@@ -172,46 +197,50 @@ impl Component for UserDetails {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut table = Self {
common: CommonComponentParts::<Self>::create(props, link),
user: None,
common: CommonComponentParts::<Self>::create(),
user_and_schema: None,
};
table.get_user_details();
table.get_user_details(ctx);
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
match (&self.user, &self.common.error) {
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
(Some(u), error) => {
fn view(&self, ctx: &Context<Self>) -> Html {
match (&self.user_and_schema, &self.common.error) {
(Some((u, schema)), error) => {
html! {
<>
<h3>{u.id.to_string()}</h3>
<UserDetailsForm
user=u.clone() />
<div class="row justify-content-center">
<NavButton
route=AppRoute::ChangePassword(u.id.clone())
classes="btn btn-primary col-auto">
{"Change password"}
</NavButton>
<div class="d-flex flex-row-reverse">
<Link
to={AppRoute::ChangePassword{user_id: u.id.clone()}}
classes="btn btn-secondary">
<i class="bi-key me-2"></i>
{"Modify password"}
</Link>
</div>
{self.view_group_memberships(u)}
{self.view_add_group_button(u)}
<div>
<h5 class="row m-3 fw-bold">{"User details"}</h5>
</div>
<UserDetailsForm
user={u.clone()}
user_attributes_schema={schema.clone()}
is_admin={ctx.props().is_admin}
is_edited_user_admin={u.groups.iter().any(|g| g.display_name == "lldap_admin")}
/>
{self.view_group_memberships(ctx, u)}
{self.view_add_group_button(ctx, u)}
{self.view_messages(error)}
</>
}
}
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
}
}
}
+186 -326
View File
@@ -1,53 +1,22 @@
use std::str::FromStr;
use crate::{
components::user_details::User,
infra::common_component::{CommonComponent, CommonComponentParts},
components::{
form::{
attribute_input::{ListAttributeInput, SingleAttributeInput},
static_value::StaticValue,
submit::Submit,
},
user_details::{Attribute, AttributeSchema, User},
},
infra::{
common_component::{CommonComponent, CommonComponentParts},
form_utils::{AttributeValue, EmailIsRequired, IsAdmin, read_all_form_attributes},
schema::AttributeType,
},
};
use anyhow::{bail, Error, Result};
use anyhow::{Ok, Result};
use gloo_console::console;
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use wasm_bindgen::JsCast;
use yew::{prelude::*, services::ConsoleService};
use yew_form_derive::Model;
#[derive(PartialEq, Eq, Clone, Default)]
struct JsFile {
file: Option<web_sys::File>,
contents: Option<Vec<u8>>,
}
impl ToString for JsFile {
fn to_string(&self) -> String {
self.file
.as_ref()
.map(web_sys::File::name)
.unwrap_or_else(String::new)
}
}
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,
#[validate(length(min = 1, message = "Display name is required"))]
display_name: String,
first_name: String,
last_name: String,
}
use yew::prelude::*;
/// The GraphQL query sent to the server to update the user details.
#[derive(GraphQLQuery)]
@@ -63,10 +32,10 @@ pub struct UpdateUser;
/// A [yew::Component] to display the user details, with a form allowing to edit them.
pub struct UserDetailsForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<UserModel>,
avatar: JsFile,
/// True if we just successfully updated the user, to display a success message.
just_updated: bool,
user: User,
form_ref: NodeRef,
}
pub enum Msg {
@@ -74,8 +43,6 @@ pub enum Msg {
Update,
/// The "Submit" button was clicked.
SubmitClicked,
/// A picked file finished loading.
FileLoaded(yew::services::reader::FileData),
/// We got the response from the server about our update message.
UserUpdated(Result<update_user::ResponseData>),
}
@@ -84,58 +51,25 @@ 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 {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => {
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let input = document
.get_element_by_id("avatarInput")
.expect("Form field avatarInput should be present")
.dyn_into::<web_sys::HtmlInputElement>()
.expect("Should be an HtmlInputElement");
ConsoleService::log("Form update");
if let Some(files) = input.files() {
ConsoleService::log("Got file list");
if files.length() > 0 {
ConsoleService::log("Got a file");
let new_avatar = JsFile {
file: files.item(0),
contents: None,
};
if self.avatar.file.as_ref().map(|f| f.name())
!= new_avatar.file.as_ref().map(|f| f.name())
{
if let Some(ref file) = new_avatar.file {
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
}
self.avatar = new_avatar;
}
}
}
Msg::Update => Ok(true),
Msg::SubmitClicked => self.submit_user_update_form(ctx),
Msg::UserUpdated(Err(e)) => Err(e),
Msg::UserUpdated(Result::Ok(_)) => {
self.just_updated = true;
Ok(true)
}
Msg::SubmitClicked => self.submit_user_update_form(),
Msg::UserUpdated(response) => self.user_update_finished(response),
Msg::FileLoaded(data) => {
self.common.cancel_task();
if let Some(file) = &self.avatar.file {
if file.name() == data.name {
if !is_valid_jpeg(data.content.as_slice()) {
// Clear the selection.
self.avatar = JsFile::default();
bail!("Chosen image is not a valid JPEG");
} else {
self.avatar.contents = Some(data.content);
return Ok(true);
}
}
}
Ok(false)
}
}
}
@@ -148,165 +82,65 @@ impl Component for UserDetailsForm {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let model = UserModel {
email: props.user.email.clone(),
display_name: props.user.display_name.clone(),
first_name: props.user.first_name.clone(),
last_name: props.user.last_name.clone(),
};
fn create(ctx: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(props, link),
form: yew_form::Form::new(model),
avatar: JsFile::default(),
common: CommonComponentParts::<Self>::create(),
just_updated: false,
user: ctx.props().user.clone(),
form_ref: NodeRef::default(),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
self.just_updated = false;
CommonComponentParts::<Self>::update(self, msg)
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
fn view(&self) -> Html {
type Field = yew_form::Field<UserModel>;
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
let avatar_string = avatar_base64.as_ref().unwrap_or(&self.common.user.avatar);
let can_edit =
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
let display_field = |a: &AttributeSchema| {
if can_edit(a) {
get_custom_attribute_input(a, &self.user.attributes)
} else {
get_custom_attribute_static(a, &self.user.attributes)
}
};
html! {
<div class="py-3">
<form class="form">
<div class="form-group row mb-3">
<label for="userId"
class="form-label col-4 col-form-label">
{"User ID: "}
</label>
<div class="col-8">
<span id="userId" class="form-constrol-static"><b>{&self.common.user.id}</b></span>
</div>
</div>
<div class="form-group row mb-3">
<div class="col-4 col-form-label">
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", avatar_string)}
style="max-height:128px;max-width:128px;height:auto;width:auto;"
alt="Avatar" />
</div>
<div class="col-8">
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput=self.common.callback(|_| Msg::Update) />
</div>
</div>
<div class="form-group row mb-3">
<label for="email"
class="form-label col-4 col-form-label">
{"Email*: "}
</label>
<div class="col-8">
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="email"
autocomplete="email"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("email")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="display_name"
class="form-label col-4 col-form-label">
{"Display Name*: "}
</label>
<div class="col-8">
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="display_name"
autocomplete="name"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("display_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="first_name"
class="form-label col-4 col-form-label">
{"First Name: "}
</label>
<div class="col-8">
<Field
class="form-control"
form=&self.form
field_name="first_name"
autocomplete="given-name"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("first_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="last_name"
class="form-label col-4 col-form-label">
{"Last Name: "}
</label>
<div class="col-8">
<Field
class="form-control"
form=&self.form
field_name="last_name"
autocomplete="family-name"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("last_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="creationDate"
class="form-label col-4 col-form-label">
{"Creation date: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-constrol-static">{&self.common.user.creation_date.date().naive_local()}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="uuid"
class="form-label col-4 col-form-label">
{"UUID: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-constrol-static">{&self.common.user.uuid}</span>
</div>
</div>
<div class="form-group row justify-content-center">
<button
type="submit"
class="btn btn-primary col-auto col-form-label"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
{"Update"}
</button>
</div>
<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 {
{
if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
@@ -314,113 +148,139 @@ impl Component for UserDetailsForm {
}
} else { html! {} }
}
<div hidden=!self.just_updated>
<span>{"User successfully updated!"}</span>
<div hidden={!self.just_updated}>
<div class="alert alert-success mt-4">{"User successfully updated!"}</div>
</div>
</div>
}
}
}
fn get_custom_attribute_input(
attribute_schema: &AttributeSchema,
user_attributes: &[Attribute],
) -> Html {
let values = user_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
if attribute_schema.is_list {
html! {
<ListAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
values={values}
/>
}
} else {
html! {
<SingleAttributeInput
name={attribute_schema.name.clone()}
attribute_type={attribute_schema.attribute_type}
value={values.first().cloned().unwrap_or_default()}
/>
}
}
}
fn get_custom_attribute_static(
attribute_schema: &AttributeSchema,
user_attributes: &[Attribute],
) -> Html {
let values = user_attributes
.iter()
.find(|a| a.name == attribute_schema.name)
.map(|attribute| attribute.value.clone())
.unwrap_or_default();
let value_to_str = match attribute_schema.attribute_type {
AttributeType::String | AttributeType::Integer => |v: String| v,
AttributeType::DateTime => |v: String| {
console!(format!("Parsing date: {}", &v));
chrono::DateTime::parse_from_rfc3339(&v)
.map(|dt| dt.naive_utc().to_string())
.unwrap_or_else(|_| "Invalid date".to_string())
},
AttributeType::JpegPhoto => |_: String| "Unimplemented JPEG display".to_string(),
};
html! {
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
{values.into_iter().map(|x| html!{<div>{value_to_str(x)}</div>}).collect::<Vec<_>>()}
</StaticValue>
}
}
impl UserDetailsForm {
fn submit_user_update_form(&mut self) -> Result<bool> {
ConsoleService::log("Submit");
if !self.form.validate() {
bail!("Invalid inputs");
}
ConsoleService::log("Valid inputs");
if let JsFile {
file: Some(_),
contents: None,
} = &self.avatar
{
bail!("Image file hasn't finished loading, try again");
}
ConsoleService::log("File is correctly loaded");
let base_user = &self.common.user;
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
// TODO: Handle unloaded files.
// if let Some(JsFile {
// file: Some(_),
// contents: None,
// }) = &self.avatar
// {
// bail!("Image file hasn't finished loading, try again");
// }
let mut all_values = read_all_form_attributes(
ctx.props().user_attributes_schema.iter(),
&self.form_ref,
IsAdmin(ctx.props().is_admin),
EmailIsRequired(!ctx.props().is_edited_user_admin),
)?;
let base_attributes = &self.user.attributes;
all_values.retain(|a| {
let base_val = base_attributes
.iter()
.find(|base_val| base_val.name == a.name);
base_val
.map(|v| v.value != a.values)
.unwrap_or(!a.values.is_empty())
});
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
None
} else {
Some(all_values.iter().map(|a| a.name.clone()).collect())
};
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
if remove_attributes.is_none() {
None
} else {
Some(
all_values
.into_iter()
.filter(|a| !a.values.is_empty())
.map(
|AttributeValue { name, values }| update_user::AttributeValueInput {
name,
value: values,
},
)
.collect(),
)
};
let mut user_input = update_user::UpdateUserInput {
id: self.common.user.id.clone(),
id: self.user.id.clone(),
email: None,
displayName: None,
firstName: None,
lastName: None,
avatar: None,
removeAttributes: None,
insertAttributes: None,
};
let default_user_input = user_input.clone();
let model = self.form.model();
let email = model.email;
if base_user.email != email {
user_input.email = Some(email);
}
if base_user.display_name != model.display_name {
user_input.displayName = Some(model.display_name);
}
if base_user.first_name != model.first_name {
user_input.firstName = Some(model.first_name);
}
if base_user.last_name != model.last_name {
user_input.lastName = Some(model.last_name);
}
user_input.avatar = maybe_to_base64(&self.avatar)?;
user_input.removeAttributes = remove_attributes;
user_input.insertAttributes = insert_attributes;
// Nothing changed.
if user_input == default_user_input {
ConsoleService::log("No changes");
return Ok(false);
}
let req = update_user::Variables { user: user_input };
ConsoleService::log("Querying");
self.common.call_graphql::<UpdateUser, _>(
ctx,
req,
Msg::UserUpdated,
"Error trying to update user",
);
Ok(false)
}
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
self.common.cancel_task();
match r {
Err(e) => return Err(e),
Ok(_) => {
let model = self.form.model();
self.common.user.email = model.email;
self.common.user.display_name = model.display_name;
self.common.user.first_name = model.first_name;
self.common.user.last_name = model.last_name;
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
self.common.user.avatar = avatar;
}
self.just_updated = true;
}
};
Ok(true)
}
}
fn is_valid_jpeg(bytes: &[u8]) -> bool {
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()
.is_ok()
}
fn maybe_to_base64(file: &JsFile) -> Result<Option<String>> {
match file {
JsFile {
file: None,
contents: _,
} => Ok(None),
JsFile {
file: Some(_),
contents: None,
} => bail!("Image file hasn't finished loading, try again"),
JsFile {
file: Some(_),
contents: Some(data),
} => {
if !is_valid_jpeg(data.as_slice()) {
bail!("Chosen image is not a valid JPEG");
}
Ok(Some(base64::encode(data)))
}
}
}
+199
View File
@@ -0,0 +1,199 @@
use crate::{
components::{
delete_user_attribute::DeleteUserAttribute,
fragments::attribute_schema::render_attribute_name,
router::{AppRoute, Link},
},
infra::{
attributes::user,
common_component::{CommonComponent, CommonComponentParts},
schema::AttributeType,
},
};
use anyhow::{Error, Result, anyhow};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_user_attributes_schema.graphql",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql",
extern_enums("AttributeType")
)]
pub struct GetUserAttributesSchema;
use get_user_attributes_schema::ResponseData;
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub hardcoded: bool,
}
pub struct UserSchemaTable {
common: CommonComponentParts<Self>,
attributes: Option<Vec<Attribute>>,
}
pub enum Msg {
ListAttributesResponse(Result<ResponseData>),
OnAttributeDeleted(String),
OnError(Error),
}
impl CommonComponent<UserSchemaTable> for UserSchemaTable {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListAttributesResponse(schema) => {
self.attributes = Some(schema?.schema.user_schema.attributes.into_iter().collect());
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnAttributeDeleted(attribute_name) => match self.attributes {
None => {
log!(format!(
"Attribute {attribute_name} was deleted but component has no attributes"
));
Err(anyhow!("invalid state"))
}
Some(_) => {
self.attributes
.as_mut()
.unwrap()
.retain(|a| a.name != attribute_name);
Ok(true)
}
},
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for UserSchemaTable {
type Message = Msg;
type Properties = Props;
fn create(ctx: &Context<Self>) -> Self {
let mut table = UserSchemaTable {
common: CommonComponentParts::<Self>::create(),
attributes: None,
};
table.common.call_graphql::<GetUserAttributesSchema, _>(
ctx,
get_user_attributes_schema::Variables {},
Msg::ListAttributesResponse,
"Error trying to fetch user schema",
);
table
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div>
{self.view_attributes(ctx)}
{self.view_errors()}
</div>
}
}
}
impl UserSchemaTable {
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
let hardcoded = ctx.props().hardcoded;
let make_table = |attributes: &Vec<Attribute>| {
html! {
<div class="table-responsive">
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
<table class="table table-hover">
<thead>
<tr>
<th>{"Attribute name"}</th>
<th>{"Type"}</th>
<th>{"Editable"}</th>
<th>{"Visible"}</th>
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
</tr>
</thead>
<tbody>
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
</tbody>
</table>
</div>
}
};
match &self.attributes {
None => html! {{"Loading..."}},
Some(attributes) => {
let mut attributes = attributes.clone();
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
make_table(&attributes)
}
}
}
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
let link = ctx.link();
let attribute_type = attribute.attribute_type;
let checkmark = html! {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
</svg>
};
let hardcoded = ctx.props().hardcoded;
let desc = user::resolve_user_attribute_description_or_default(&attribute.name);
html! {
<tr key={attribute.name.clone()}>
<td>{render_attribute_name(hardcoded, &desc)}</td>
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
{
if hardcoded {
html!{}
} else {
html!{
<td>
<DeleteUserAttribute
attribute_name={attribute.name.clone()}
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
on_error={link.callback(Msg::OnError)}/>
</td>
}
}
}
</tr>
}
}
fn view_errors(&self) -> Html {
match &self.common.error {
None => html! {},
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
}
}
}
#[function_component(ListUserSchema)]
pub fn list_user_schema() -> Html {
html! {
<div>
<UserSchemaTable hardcoded={true} />
<UserSchemaTable hardcoded={false} />
<Link classes="btn btn-primary" to={AppRoute::CreateUserAttribute}>
<i class="bi-plus-circle me-2"></i>
{"Create an attribute"}
</Link>
</div>
}
}
+21 -23
View File
@@ -34,7 +34,7 @@ pub enum Msg {
}
impl CommonComponent<UserTable> for UserTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListUsersResponse(users) => {
self.users = Some(users?.users.into_iter().collect());
@@ -55,8 +55,9 @@ impl CommonComponent<UserTable> for UserTable {
}
impl UserTable {
fn get_users(&mut self, req: Option<RequestFilter>) {
fn get_users(&mut self, ctx: &Context<Self>, req: Option<RequestFilter>) {
self.common.call_graphql::<ListUsersQuery, _>(
ctx,
list_users_query::Variables { filters: req },
Msg::ListUsersResponse,
"Error trying to fetch users",
@@ -68,27 +69,23 @@ impl Component for UserTable {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut table = UserTable {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
users: None,
};
table.get_users(None);
table.get_users(ctx, None);
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div>
{self.view_users()}
{self.view_users(ctx)}
{self.view_errors()}
</div>
}
@@ -96,11 +93,11 @@ impl Component for UserTable {
}
impl UserTable {
fn view_users(&self) -> Html {
fn view_users(&self, ctx: &Context<Self>) -> Html {
let make_table = |users: &Vec<User>| {
html! {
<div class="table-responsive">
<table class="table table-striped">
<table class="table table-hover">
<thead>
<tr>
<th>{"User ID"}</th>
@@ -113,7 +110,7 @@ impl UserTable {
</tr>
</thead>
<tbody>
{users.iter().map(|u| self.view_user(u)).collect::<Vec<_>>()}
{users.iter().map(|u| self.view_user(ctx, u)).collect::<Vec<_>>()}
</tbody>
</table>
</div>
@@ -125,20 +122,21 @@ impl UserTable {
}
}
fn view_user(&self, user: &User) -> Html {
fn view_user(&self, ctx: &Context<Self>, user: &User) -> Html {
let link = &ctx.link();
html! {
<tr key=user.id.clone()>
<td><Link route=AppRoute::UserDetails(user.id.clone())>{&user.id}</Link></td>
<tr key={user.id.clone()}>
<td><Link to={AppRoute::UserDetails{user_id: user.id.clone()}}>{&user.id}</Link></td>
<td>{&user.email}</td>
<td>{&user.display_name}</td>
<td>{&user.first_name}</td>
<td>{&user.last_name}</td>
<td>{&user.creation_date.date().naive_local()}</td>
<td>{&user.creation_date.naive_local().date()}</td>
<td>
<DeleteUser
username=user.id.clone()
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
on_error=self.common.callback(Msg::OnError)/>
username={user.id.clone()}
on_user_deleted={link.callback(Msg::OnUserDeleted)}
on_error={link.callback(Msg::OnError)}/>
</td>
</tr>
}
+122 -196
View File
@@ -1,136 +1,96 @@
use super::cookies::set_cookie;
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result, anyhow};
use gloo_net::http::{Method, RequestBuilder};
use graphql_client::GraphQLQuery;
use lldap_auth::{login, registration, JWTClaims};
use lldap_auth::{JWTClaims, login, registration};
use yew::callback::Callback;
use yew::format::Json;
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
use lldap_frontend_options::Options;
use serde::{Serialize, de::DeserializeOwned};
use web_sys::RequestCredentials;
#[derive(Default)]
pub struct HostService {}
fn get_default_options() -> FetchOptions {
FetchOptions {
credentials: Some(Credentials::SameOrigin),
..FetchOptions::default()
}
}
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
use jwt::*;
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
Ok(token.claims().clone())
}
fn create_handler<Resp, CallbackResult, F>(
callback: Callback<Result<CallbackResult>>,
handler: F,
) -> Callback<Response<Result<Resp>>>
where
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
CallbackResult: 'static,
{
Callback::once(move |response: Response<Result<Resp>>| {
let (meta, maybe_data) = response.into_parts();
let message = maybe_data
.context("Could not reach server")
.and_then(|data| handler(meta.status, data));
callback.emit(message)
})
enum RequestType<Body: Serialize> {
Get,
Post(Body),
}
struct RequestBody<T>(T);
const GET_REQUEST: RequestType<()> = RequestType::Get;
impl<'a, R> From<&'a R> for RequestBody<Json<&'a R>>
where
R: serde::ser::Serialize,
{
fn from(request: &'a R) -> Self {
Self(Json(request))
fn base_url() -> String {
yew_router::utils::base_url().unwrap_or_default()
}
async fn call_server<Body: Serialize>(
url: &str,
body: RequestType<Body>,
error_message: &'static str,
) -> Result<String> {
let request_builder = RequestBuilder::new(url)
.header("Content-Type", "application/json")
.credentials(RequestCredentials::SameOrigin);
let request = if let RequestType::Post(b) = body {
request_builder
.method(Method::POST)
.body(serde_json::to_string(&b)?)?
} else {
request_builder.build()?
};
let response = request.send().await?;
if response.ok() {
Ok(response.text().await?)
} else {
Err(anyhow!(
"{}[{} {}]: {}",
error_message,
response.status(),
response.status_text(),
response.text().await?
))
}
}
impl From<yew::format::Nothing> for RequestBody<yew::format::Nothing> {
fn from(request: yew::format::Nothing) -> Self {
Self(request)
}
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
url: &str,
request: RequestType<Body>,
error_message: &'static str,
) -> Result<CallbackResult>
where
CallbackResult: DeserializeOwned + 'static,
{
let data = call_server(url, request, error_message).await?;
serde_json::from_str(&data).context("Could not parse response")
}
fn call_server<Req, CallbackResult, F, RB>(
async fn call_server_empty_response_with_error_message<Body: Serialize>(
url: &str,
request: RB,
callback: Callback<Result<CallbackResult>>,
request: RequestType<Body>,
error_message: &'static str,
parse_response: F,
) -> Result<FetchTask>
where
F: Fn(String) -> Result<CallbackResult> + 'static,
CallbackResult: 'static,
RB: Into<RequestBody<Req>>,
Req: Into<yew::format::Text>,
{
let request = {
// If the request type is empty (if the size is 0), it's a get.
if std::mem::size_of::<RB>() == 0 {
Request::get(url)
} else {
Request::post(url)
}
}
.header("Content-Type", "application/json")
.body(request.into().0)?;
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
if status.is_success() {
parse_response(data)
} else {
Err(anyhow!("{}[{}]: {}", error_message, status, data))
}
});
FetchService::fetch_with_options(request, get_default_options(), handler)
) -> Result<()> {
call_server(url, request, error_message).await.map(|_| ())
}
fn call_server_json_with_error_message<CallbackResult, RB, Req>(
url: &str,
request: RB,
callback: Callback<Result<CallbackResult>>,
error_message: &'static str,
) -> Result<FetchTask>
where
CallbackResult: serde::de::DeserializeOwned + 'static,
RB: Into<RequestBody<Req>>,
Req: Into<yew::format::Text>,
{
call_server(url, request, callback, error_message, |data: String| {
serde_json::from_str(&data).context("Could not parse response")
})
}
fn call_server_empty_response_with_error_message<RB, Req>(
url: &str,
request: RB,
callback: Callback<Result<()>>,
error_message: &'static str,
) -> Result<FetchTask>
where
RB: Into<RequestBody<Req>>,
Req: Into<yew::format::Text>,
{
call_server(
url,
request,
callback,
error_message,
|_data: String| Ok(()),
)
fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, bool)> {
let jwt_claims = get_claims_from_jwt(response.token.as_str()).context("Could not parse JWT")?;
let is_admin = jwt_claims.groups.contains("lldap_admin");
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
.map(|_| (jwt_claims.user.clone(), is_admin))
.context("Error setting cookie")
}
impl HostService {
pub fn graphql_query<QueryType>(
pub async fn graphql_query<QueryType>(
variables: QueryType::Variables,
callback: Callback<Result<QueryType::ResponseData>>,
error_message: &'static str,
) -> Result<FetchTask>
) -> Result<QueryType::ResponseData>
where
QueryType: GraphQLQuery + 'static,
{
@@ -147,143 +107,109 @@ impl HostService {
)
})
};
let parse_graphql_response = move |data: String| {
serde_json::from_str(&data)
.context("Could not parse response")
.and_then(unwrap_graphql_response)
};
let request_body = QueryType::build_query(variables);
call_server(
"/api/graphql",
&request_body,
callback,
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
&(base_url() + "/api/graphql"),
RequestType::Post(request_body),
error_message,
parse_graphql_response,
)
.await
.and_then(unwrap_graphql_response)
}
pub fn login_start(
pub async fn login_start(
request: login::ClientLoginStartRequest,
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>,
) -> Result<FetchTask> {
) -> Result<Box<login::ServerLoginStartResponse>> {
call_server_json_with_error_message(
"/auth/opaque/login/start",
&request,
callback,
&(base_url() + "/auth/opaque/login/start"),
RequestType::Post(request),
"Could not start authentication: ",
)
.await
}
pub fn login_finish(
request: login::ClientLoginFinishRequest,
callback: Callback<Result<(String, bool)>>,
) -> Result<FetchTask> {
let set_cookies = |jwt_claims: JWTClaims| {
let is_admin = jwt_claims.groups.contains("lldap_admin");
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
.map(|_| (jwt_claims.user.clone(), is_admin))
.context("Error clearing cookie")
};
let parse_token = move |data: String| {
serde_json::from_str::<login::ServerLoginResponse>(&data)
.context("Could not parse response")
.and_then(|r| {
get_claims_from_jwt(r.token.as_str())
.context("Could not parse response")
.and_then(set_cookies)
})
};
call_server(
"/auth/opaque/login/finish",
&request,
callback,
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/opaque/login/finish"),
RequestType::Post(request),
"Could not finish authentication",
parse_token,
)
.await
.and_then(set_cookies_from_jwt)
}
pub fn register_start(
pub async fn get_settings() -> Result<Options> {
call_server_json_with_error_message::<Options, _>(
&(base_url() + "/settings"),
GET_REQUEST,
"Could not fetch settings: ",
)
.await
}
pub async fn register_start(
request: registration::ClientRegistrationStartRequest,
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
) -> Result<FetchTask> {
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
call_server_json_with_error_message(
"/auth/opaque/register/start",
&request,
callback,
&(base_url() + "/auth/opaque/register/start"),
RequestType::Post(request),
"Could not start registration: ",
)
.await
}
pub fn register_finish(
pub async fn register_finish(
request: registration::ClientRegistrationFinishRequest,
callback: Callback<Result<()>>,
) -> Result<FetchTask> {
) -> Result<()> {
call_server_empty_response_with_error_message(
"/auth/opaque/register/finish",
&request,
callback,
&(base_url() + "/auth/opaque/register/finish"),
RequestType::Post(request),
"Could not finish registration",
)
.await
}
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
let set_cookies = |jwt_claims: JWTClaims| {
let is_admin = jwt_claims.groups.contains("lldap_admin");
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
.map(|_| (jwt_claims.user.clone(), is_admin))
.context("Error clearing cookie")
};
let parse_token = move |data: String| {
serde_json::from_str::<login::ServerLoginResponse>(&data)
.context("Could not parse response")
.and_then(|r| {
get_claims_from_jwt(r.token.as_str())
.context("Could not parse response")
.and_then(set_cookies)
})
};
call_server(
"/auth/refresh",
yew::format::Nothing,
callback,
pub async fn refresh() -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/refresh"),
GET_REQUEST,
"Could not start authentication: ",
parse_token,
)
.await
.and_then(set_cookies_from_jwt)
}
// The `_request` parameter is to make it the same shape as the other functions.
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
pub async fn logout() -> Result<()> {
call_server_empty_response_with_error_message(
"/auth/logout",
yew::format::Nothing,
callback,
&(base_url() + "/auth/logout"),
GET_REQUEST,
"Could not logout",
)
.await
}
pub fn reset_password_step1(
username: &str,
callback: Callback<Result<()>>,
) -> Result<FetchTask> {
pub async fn reset_password_step1(username: String) -> Result<()> {
call_server_empty_response_with_error_message(
&format!("/auth/reset/step1/{}", username),
yew::format::Nothing,
callback,
&format!(
"{}/auth/reset/step1/{}",
base_url(),
url_escape::encode_query(&username)
),
RequestType::Post(""),
"Could not initiate password reset",
)
.await
}
pub fn reset_password_step2(
token: &str,
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
) -> Result<FetchTask> {
pub async fn reset_password_step2(
token: String,
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
call_server_json_with_error_message(
&format!("/auth/reset/step2/{}", token),
yew::format::Nothing,
callback,
&format!("{}/auth/reset/step2/{}", base_url(), token),
GET_REQUEST,
"Could not validate token",
)
.await
}
}
+109
View File
@@ -0,0 +1,109 @@
pub struct AttributeDescription<'a> {
pub attribute_identifier: &'a str,
pub attribute_name: &'a str,
pub aliases: Vec<&'a str>,
}
pub mod group {
use super::AttributeDescription;
pub fn resolve_group_attribute_description(name: &str) -> Option<AttributeDescription> {
match name {
"creation_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "creationdate",
aliases: vec![name, "createtimestamp", "modifytimestamp"],
}),
"display_name" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "displayname",
aliases: vec![name, "cn", "uid", "id"],
}),
"group_id" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "groupid",
aliases: vec![name],
}),
"uuid" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec!["entryuuid"],
}),
_ => None,
}
}
pub fn resolve_group_attribute_description_or_default(name: &str) -> AttributeDescription {
match resolve_group_attribute_description(name) {
Some(d) => d,
None => AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec![],
},
}
}
}
pub mod user {
use super::AttributeDescription;
pub fn resolve_user_attribute_description(name: &str) -> Option<AttributeDescription> {
match name {
"avatar" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec!["jpegphoto"],
}),
"creation_date" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "creationdate",
aliases: vec![name, "createtimestamp", "modifytimestamp"],
}),
"display_name" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "displayname",
aliases: vec![name, "cn"],
}),
"first_name" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "firstname",
aliases: vec![name, "givenname"],
}),
"last_name" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "lastname",
aliases: vec![name, "sn"],
}),
"mail" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec!["email"],
}),
"user_id" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: "uid",
aliases: vec![name, "id"],
}),
"uuid" => Some(AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec!["entryuuid"],
}),
_ => None,
}
}
pub fn resolve_user_attribute_description_or_default(name: &str) -> AttributeDescription {
match resolve_user_attribute_description(name) {
Some(d) => d,
None => AttributeDescription {
attribute_identifier: name,
attribute_name: name,
aliases: vec![],
},
}
}
}
+50 -119
View File
@@ -21,88 +21,62 @@
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
//! take care of error and task handling.
use std::{
future::Future,
marker::PhantomData,
sync::{Arc, Mutex},
};
use crate::infra::api::HostService;
use anyhow::{Error, Result};
use gloo_console::error;
use graphql_client::GraphQLQuery;
use yew::{
prelude::*,
services::{
fetch::FetchTask,
reader::{FileData, ReaderService, ReaderTask},
ConsoleService,
},
};
use yewtil::NeqAssign;
use yew::prelude::*;
/// Trait required for common components.
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
/// Handle the incoming message. If an error is returned here, any running task will be
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
/// component will be refreshed.
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool>;
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
}
enum AnyTask {
None,
FetchTask(FetchTask),
ReaderTask(ReaderTask),
}
impl AnyTask {
fn is_some(&self) -> bool {
!matches!(self, AnyTask::None)
}
}
impl From<Option<FetchTask>> for AnyTask {
fn from(task: Option<FetchTask>) -> Self {
match task {
Some(t) => AnyTask::FetchTask(t),
None => AnyTask::None,
}
}
}
/// Structure that contains the common parts needed by most components.
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
pub struct CommonComponentParts<C: CommonComponent<C>> {
link: ComponentLink<C>,
pub props: <C as Component>::Properties,
pub error: Option<Error>,
task: AnyTask,
is_task_running: Arc<Mutex<bool>>,
_phantom: PhantomData<C>,
}
impl<C: CommonComponent<C>> CommonComponentParts<C> {
pub fn create() -> Self {
CommonComponentParts {
error: None,
is_task_running: Arc::new(Mutex::new(false)),
_phantom: PhantomData::<C>,
}
}
/// Whether there is a currently running task in the background.
pub fn is_task_running(&self) -> bool {
self.task.is_some()
}
/// Cancel any background task.
pub fn cancel_task(&mut self) {
self.task = AnyTask::None;
}
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
Self {
link,
props,
error: None,
task: AnyTask::None,
}
*self.is_task_running.lock().unwrap()
}
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
/// [`CommonComponent::handle_msg`] and handle any resulting error.
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
com.mut_common().error = None;
match com.handle_msg(msg) {
match com.handle_msg(ctx, msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
error!(&e.to_string());
com.mut_common().error = Some(e);
com.mut_common().cancel_task();
assert!(!*com.mut_common().is_task_running.lock().unwrap());
true
}
Ok(b) => b,
@@ -112,10 +86,11 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
/// Same as above, but the resulting error is instead passed to the reporting function.
pub fn update_and_report_error(
com: &mut C,
ctx: &Context<C>,
msg: <C as Component>::Message,
report_fn: Callback<Error>,
) -> ShouldRender {
let should_render = Self::update(com, msg);
) -> bool {
let should_render = Self::update(com, ctx, msg);
com.mut_common()
.error
.take()
@@ -126,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
.unwrap_or(should_render)
}
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
/// properties have changed and return whether the component should update.
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
where
<C as yew::Component>::Properties: std::cmp::PartialEq,
{
self.props.neq_assign(props)
}
/// Create a callback from the link.
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
where
M: Into<C::Message>,
F: Fn(IN) -> M + 'static,
{
self.link.callback(function)
}
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
/// result. Returns whether _starting the call_ failed.
pub fn call_backend<M, Req, Cb, Resp>(
&mut self,
method: M,
req: Req,
callback: Cb,
) -> Result<()>
/// result.
pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
where
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
Fut: Future<Output = Resp> + 'static,
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
{
self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?);
Ok(())
{
let mut running = self.is_task_running.lock().unwrap();
assert!(!*running);
*running = true;
}
let is_task_running = self.is_task_running.clone();
ctx.link().send_future(async move {
let res = fut.await;
*is_task_running.lock().unwrap() = false;
callback(res)
});
}
/// Call the backend with a GraphQL query.
@@ -165,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
/// `EnumCallback` should usually be left as `_`.
pub fn call_graphql<QueryType, EnumCallback>(
&mut self,
ctx: &Context<C>,
variables: QueryType::Variables,
enum_callback: EnumCallback,
error_message: &'static str,
@@ -172,41 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
QueryType: GraphQLQuery + 'static,
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
{
self.task = HostService::graphql_query::<QueryType>(
variables,
self.link.callback(enum_callback),
error_message,
)
.map_err::<(), _>(|e| {
ConsoleService::log(&e.to_string());
self.error = Some(e);
})
.ok()
.into();
}
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
where
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
{
self.task = AnyTask::ReaderTask(ReaderService::read_file(
file,
self.link.callback_once(callback),
)?);
Ok(())
}
}
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
type Target = <C as Component>::Properties;
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
&self.props
}
}
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
&mut self.props
self.call_backend(
ctx,
HostService::graphql_query::<QueryType>(variables, error_message),
enum_callback,
);
}
}
+9 -4
View File
@@ -1,4 +1,4 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use chrono::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::HtmlDocument;
@@ -22,10 +22,11 @@ pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) ->
.map_err(|_| anyhow!("Document is not an HTMLDocument"))
})?;
let cookie_string = format!(
"{}={}; expires={}; sameSite=Strict; path=/",
"{}={}; expires={}; sameSite=Strict; path={}/",
cookie_name,
value,
expiration.to_rfc2822()
expiration.to_rfc2822(),
yew_router::utils::base_url().unwrap_or_default()
);
doc.set_cookie(&cookie_string)
.map_err(|_| anyhow!("Could not set cookie"))
@@ -53,7 +54,11 @@ pub fn get_cookie(cookie_name: &str) -> Result<Option<String>> {
pub fn delete_cookie(cookie_name: &str) -> Result<()> {
if get_cookie(cookie_name)?.is_some() {
set_cookie(cookie_name, "", &Utc.ymd(1970, 1, 1).and_hms(0, 0, 0))
set_cookie(
cookie_name,
"",
&Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(),
)
} else {
Ok(())
}
+68
View File
@@ -0,0 +1,68 @@
use anyhow::{Result, anyhow, ensure};
use validator::validate_email;
use web_sys::{FormData, HtmlFormElement};
use yew::NodeRef;
#[derive(Debug)]
pub struct AttributeValue {
pub name: String,
pub values: Vec<String>,
}
pub struct GraphQlAttributeSchema {
pub name: String,
pub is_list: bool,
pub is_readonly: bool,
pub is_editable: bool,
}
fn validate_email_attributes(all_values: &[AttributeValue]) -> Result<()> {
let maybe_email_values = all_values.iter().find(|a| a.name == "mail");
let email_values = &maybe_email_values
.ok_or_else(|| anyhow!("Email is required"))?
.values;
ensure!(!email_values.is_empty(), "Email is required");
ensure!(email_values.len() == 1, "Multiple emails are not supported");
ensure!(validate_email(&email_values[0]), "Email is not valid");
Ok(())
}
pub struct IsAdmin(pub bool);
pub struct EmailIsRequired(pub bool);
pub fn read_all_form_attributes(
schema: impl IntoIterator<Item = impl Into<GraphQlAttributeSchema>>,
form_ref: &NodeRef,
is_admin: IsAdmin,
email_is_required: EmailIsRequired,
) -> Result<Vec<AttributeValue>> {
let form = form_ref.cast::<HtmlFormElement>().unwrap();
let form_data = FormData::new_with_form(&form)
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
let all_values = schema
.into_iter()
.map(Into::<GraphQlAttributeSchema>::into)
.filter(|attr| !attr.is_readonly && (is_admin.0 || attr.is_editable))
.map(|attr| -> Result<AttributeValue> {
let val = form_data
.get_all(attr.name.as_str())
.iter()
.map(|js_val| js_val.as_string().unwrap_or_default())
.filter(|val| !val.is_empty())
.collect::<Vec<String>>();
ensure!(
val.len() <= 1 || attr.is_list,
"Multiple values supplied for non-list attribute {}",
attr.name
);
Ok(AttributeValue {
name: attr.name.clone(),
values: val,
})
})
.collect::<Result<Vec<_>>>()?;
if email_is_required.0 {
validate_email_attributes(&all_values)?;
}
Ok(all_values)
}
+59
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::{UseStateHandle, use_effect_with_deps, use_state_eq};
// Enum to represent a result that is fetched asynchronously.
#[derive(Debug)]
pub enum LoadableResult<T> {
// The result is still being fetched
Loading,
// The async call is completed
Loaded(Result<T>),
}
impl<T: PartialEq> PartialEq for LoadableResult<T> {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(LoadableResult::Loading, LoadableResult::Loading) => true,
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
e1.to_string().eq(&e2.to_string())
}
_ => false,
}
}
}
pub fn use_graphql_call<QueryType>(
variables: QueryType::Variables,
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
where
QueryType: GraphQLQuery + 'static,
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
{
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
use_state_eq(|| LoadableResult::Loading);
{
let loadable_result = loadable_result.clone();
use_effect_with_deps(
move |variables| {
let task = HostService::graphql_query::<QueryType>(
variables.clone(),
"Failed graphql query",
);
spawn_local(async move {
let response = task.await;
loadable_result.set(LoadableResult::Loaded(response));
});
|| ()
},
variables,
)
}
loadable_result.clone()
}
+5
View File
@@ -1,5 +1,10 @@
pub mod api;
pub mod attributes;
pub mod common_component;
pub mod cookies;
pub mod form_utils;
pub mod functional;
pub mod graphql;
pub mod modal;
pub mod schema;
pub mod tooltip;
+7 -5
View File
@@ -1,16 +1,18 @@
#![allow(clippy::empty_docs)]
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "bootstrap")]
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen]
#[wasm_bindgen(js_namespace = bootstrap)]
pub type Modal;
#[wasm_bindgen(constructor)]
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
pub fn new(e: web_sys::Element) -> Modal;
#[wasm_bindgen(method)]
#[wasm_bindgen(method, js_namespace = bootstrap)]
pub fn show(this: &Modal);
#[wasm_bindgen(method)]
#[wasm_bindgen(method, js_namespace = bootstrap)]
pub fn hide(this: &Modal);
}
+42
View File
@@ -0,0 +1,42 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
use strum::EnumString;
use validator::ValidationError;
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, EnumString, Display)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(ascii_case_insensitive)]
pub(crate) enum AttributeType {
String,
Integer,
#[strum(serialize = "DATE_TIME", serialize = "DATETIME")]
DateTime,
#[strum(serialize = "JPEG_PHOTO", serialize = "JPEGPHOTO")]
JpegPhoto,
}
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
AttributeType::try_from(attribute_type)
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_attribute_type() {
let attr_type: AttributeType = "STRING".try_into().unwrap();
assert_eq!(attr_type, AttributeType::String);
let attr_type: AttributeType = "Integer".try_into().unwrap();
assert_eq!(attr_type, AttributeType::Integer);
let attr_type: AttributeType = "DATE_TIME".try_into().unwrap();
assert_eq!(attr_type, AttributeType::DateTime);
let attr_type: AttributeType = "JpegPhoto".try_into().unwrap();
assert_eq!(attr_type, AttributeType::JpegPhoto);
}
}
+12
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;
}
+5 -3
View File
@@ -1,14 +1,16 @@
#![recursion_limit = "256"]
#![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)]
#![allow(clippy::uninlined_format_args)]
#![allow(clippy::let_unit_value)]
pub mod components;
pub mod infra;
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
use wasm_bindgen::prelude::{JsValue, wasm_bindgen};
#[wasm_bindgen]
pub fn run_app() -> Result<(), JsValue> {
yew::start_app::<components::app::App>();
yew::start_app::<components::app::AppContainer>();
Ok(())
}
+2 -1
View File
@@ -1,4 +1,5 @@
https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css
https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
+10
View File
@@ -0,0 +1,10 @@
import init, { run_app } from '/pkg/lldap_app.js';
async function main() {
if(navigator.userAgent.indexOf('AppleWebKit') != -1) {
await init('/pkg/lldap_app_bg.wasm');
} else {
await init('/pkg/lldap_app_bg.wasm.gz');
}
run_app();
}
main()
+21 -1
View File
@@ -1,4 +1,4 @@
header h1 {
header h2 {
font-family: 'Bebas Neue', cursive;
}
@@ -10,3 +10,23 @@ header h1 {
font-weight: 700;
text-decoration: none;
}
html.dark .bg-light {
background-color: rgba(59,59,59,1) !important;
}
html.dark a {
color: #e1e1e1
}
a {
color: #212529
}
html.dark .nav-link {
color: #e1e1e1
}
.nav-link {
color: #212529
}
-36
View File
@@ -1,36 +0,0 @@
[package]
name = "lldap_auth"
version = "0.3.0-alpha.1"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
[features]
default = ["opaque_server", "opaque_client"]
opaque_server = []
opaque_client = []
js = []
[dependencies]
rust-argon2 = "0.8"
curve25519-dalek = "3"
digest = "0.9"
generic-array = "*"
rand = "0.8"
serde = "*"
sha2 = "0.9"
thiserror = "*"
[dependencies.opaque-ke]
version = "0.6"
[dependencies.chrono]
version = "*"
features = [ "serde" ]
# For WASM targets, use the JS getrandom.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
version = "0.2"
features = ["js"]
[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom]
version = "0.2"
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "lldap_access_control"
version = "0.1.0"
description = "Access control wrappers for LLDAP"
authors.workspace = true
edition.workspace = true
homepage.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tracing = "*"
async-trait = "0.1"
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_server", "opaque_client", "sea_orm"]
[dependencies.lldap_domain]
path = "../domain"
[dependencies.lldap_domain_handlers]
path = "../domain-handlers"
[dependencies.lldap_domain_model]
path = "../domain-model"

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