Compare commits

...

142 Commits
v0.4.1 ... main

Author SHA1 Message Date
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
133 changed files with 10454 additions and 6069 deletions

2
.config/nextest.toml Normal file
View File

@ -0,0 +1,2 @@
[profile.default]
fail-fast = false

24
.devcontainer/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM rust:1.66
ARG USERNAME=lldapdev
ARG USER_UID=1000
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

View File

@ -0,0 +1,8 @@
{
"name": "LLDAP dev",
"build": { "dockerfile": "Dockerfile" },
"forwardPorts": [
3890,
17170
]
}

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,6 +30,10 @@ package.json
# Pre-build binaries
*.tar.gz
# VSCode dirs
.vscode
.devcontainer
# Various config files that shouldn't be tracked
.env
lldap_config.toml

10
.gitattributes vendored Normal file
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
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @nitnelave

2
.github/codecov.yml vendored
View File

@ -10,3 +10,5 @@ ignore:
- "docs"
- "example_configs"
- "migration-tool"
- "scripts"
- "set-password"

View File

@ -10,28 +10,34 @@ 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 && \
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-bin/lldap target/lldap && \
mv bin/aarch64-bin/migration-tool target/migration-tool && \
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armhf-bin/lldap target/lldap && \
mv bin/armhf-bin/migration-tool target/migration-tool && \
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap target/lldap && \
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
@ -42,6 +48,7 @@ COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \
cp target/migration-tool /lldap/ && \
cp target/lldap_set_password /lldap/ && \
cp -R web/index.html \
web/pkg \
web/static \
@ -52,7 +59,7 @@ 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
@ -86,7 +93,7 @@ RUN set -eux; \
# verify that the binary works
gosu --version; \
gosu nobody true
RUN apk add --no-cache tini ca-certificates bash && \
RUN apk add --no-cache tini ca-certificates bash tzdata && \
addgroup -g $GID $USER && \
adduser \
--disabled-password \
@ -98,9 +105,10 @@ RUN apk add --no-cache tini ca-certificates bash && \
"$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
COPY --from=lldap --chown=$USER:$USER /lldap /app
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
VOLUME ["/data"]
WORKDIR /app
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]

View File

@ -10,28 +10,34 @@ 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 && \
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-bin/lldap target/lldap && \
mv bin/aarch64-bin/migration-tool target/migration-tool && \
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armhf-bin/lldap target/lldap && \
mv bin/armhf-bin/migration-tool target/migration-tool && \
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap target/lldap && \
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \
pwd \
; fi
@ -42,6 +48,7 @@ COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \
cp target/migration-tool /lldap/ && \
cp target/lldap_set_password /lldap/ && \
cp -R web/index.html \
web/pkg \
web/static \
@ -51,14 +58,14 @@ 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 .
&& 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 install -y --no-install-recommends tini openssl ca-certificates gosu tzdata && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
@ -69,3 +76,4 @@ VOLUME ["/data"]
WORKDIR /app
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]

View File

@ -1,28 +1,34 @@
FROM rust:1.62-slim-bullseye
# Keep tracking base image
FROM rust:1.66-slim-bullseye
# 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"
### Install build deps x86_64
RUN apt update && \
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools && \
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools gzip && \
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt update && \
apt install -y --no-install-recommends nodejs && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
npm install -g npm && \
npm install -g yarn && \
npm install -g pnpm
rm -rf /var/lib/apt/lists/*
### Install build deps aarch64 build
RUN dpkg --add-architecture arm64 && \
apt update && \
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross && \
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross gzip && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
rustup target add aarch64-unknown-linux-gnu
### armhf deps
RUN dpkg --add-architecture armhf && \
apt update && \
apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross gzip && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
rustup target add armv7-unknown-linux-gnueabihf
### Add musl-gcc aarch64 and x86_64
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
tar zxf ./x86_64-linux-musl-cross.tgz -C /opt && \
@ -31,4 +37,9 @@ RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
rm ./x86_64-linux-musl-cross.tgz && \
rm ./aarch64-linux-musl-cross.tgz
### Add musl target
RUN rustup target add x86_64-unknown-linux-musl && \
rustup target add aarch64-unknown-linux-musl
CMD ["bash"]

View File

@ -4,12 +4,18 @@ on:
push:
branches:
- 'main'
paths-ignore:
- 'docs/**'
- 'example_configs/**'
release:
types:
- 'published'
pull_request:
branches:
- 'main'
paths-ignore:
- 'docs/**'
- 'example_configs/**'
workflow_dispatch:
inputs:
msg:
@ -19,55 +25,71 @@ on:
env:
CARGO_TERM_COLOR: always
# In total 5 jobs, all of the jobs are containerized
# ---
### CI Docs
# build-ui , create/compile the web
## Use rustlang/rust:nighlty image
### Install nodejs from nodesource repo
### install wasm
### install rollup
### run app/build.sh
### upload artifacts
# builds-armhf, build-aarch64, build-amd64 create binary for respective arch
## Use rustlang/rust:nightly image
### Add non native architecture dpkg --add-architecture XXX
### Install dev tool gcc g++, etc per respective arch
# build-bin
## build-armhf, build-aarch64, build-amd64 , create binary for respective arch
#######################################################################################
# GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
# Look into .github/workflows/Dockerfile.dev for development image details #
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
#######################################################################################
### Cargo build
### Upload artifacts
## the CARGO_ env
#CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
# This will determine which architecture lib will be used.
### aarch64 and amd64 is musl based
### armv7 is glibc based, musl had issue with time_t when cross compile https://github.com/rust-lang/libc/issues/1848
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
# lldap-test
### will run lldap with postgres, mariadb and sqlite backend, do selfcheck command.
# Build docker image
### Triplet docker image arch with debian base
### amd64 & aarch64 with alpine base
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
### Look into .github/workflows/Dockerfile.ci.debian or .github/workflowds/Dockerfile.ci.alpine
# On current https://hub.docker.com/_/rust
# 1-bullseye, 1.61-bullseye, 1.61.0-bullseye, bullseye, 1, 1.61, 1.61.0, latest
# create release artifacts
### Fetch artifacts
### Clean up web artifact
### Setup folder structure
### Compress
### Upload
# cache
## cargo
## target
# cache based on Cargo.lock per cargo target
jobs:
pre_job:
continue-on-error: true
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }}
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@master
with:
concurrent_skipping: 'outdated_runs'
skip_after_successful_duplicate: ${{ github.ref != 'refs/heads/main' }}
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".gitignore", "lldap_config.docker_template.toml"]'
do_not_skip: '["workflow_dispatch", "schedule"]'
cancel_others: true
build-ui:
runs-on: ubuntu-latest
needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
container:
image: rust:1.62
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
image: nitnelave/rust-dev:latest
steps:
- name: install runtime
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev ca-certificates
- name: setup node repo LTS
run: curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
- name: install nodejs
run: apt install -y nodejs && npm -g install npm
- name: smoke test
run: rustc --version
- name: Checkout repository
uses: actions/checkout@v3.5.0
- uses: actions/cache@v3
with:
path: |
@ -79,142 +101,44 @@ jobs:
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-ui-
- name: Checkout repository
uses: actions/checkout@v3.1.0
- name: install rollup nodejs
- name: Install rollup (nodejs)
run: npm install -g rollup
- name: install wasm-pack with cargo
- name: Add wasm target (rust)
run: rustup target add wasm32-unknown-unknown
- name: Install wasm-pack with cargo
run: cargo install wasm-pack || true
env:
RUSTFLAGS: ""
- name: build frontend
- name: Build frontend
run: ./app/build.sh
- name: check path
- name: Check build path
run: ls -al app/
- name: upload ui artifacts
- name: Upload ui artifacts
uses: actions/upload-artifact@v3
with:
name: ui
path: app/
build-armhf:
build-bin:
runs-on: ubuntu-latest
needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }}
strategy:
matrix:
target: [armv7-unknown-linux-gnueabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
container:
image: rust:1.62
image: nitnelave/rust-dev:latest
env:
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-gnueabihf-ld
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=-crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps:
- name: add armhf architecture
run: dpkg --add-architecture armhf
- name: install runtime
run: apt update && apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross tar ca-certificates
- name: smoke test
run: rustc --version
- name: add armhf target
run: rustup target add armv7-unknown-linux-gnueabihf
- name: smoke test
run: rustc --version
- name: Checkout repository
uses: actions/checkout@v3.1.0
- uses: actions/cache@v3
with:
path: |
.cargo/bin
.cargo/registry/index
.cargo/registry/cache
.cargo/git/db
target
key: lldap-bin-armhf-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-bin-armhf-
- name: compile armhf
run: cargo build --target=armv7-unknown-linux-gnueabihf --release -p lldap -p migration-tool
- name: check path
run: ls -al target/release
- name: upload armhf lldap artifacts
uses: actions/upload-artifact@v3
with:
name: armhf-lldap-bin
path: target/armv7-unknown-linux-gnueabihf/release/lldap
- name: upload armhfmigration-tool artifacts
uses: actions/upload-artifact@v3
with:
name: armhf-migration-tool-bin
path: target/armv7-unknown-linux-gnueabihf/release/migration-tool
build-aarch64:
runs-on: ubuntu-latest
container:
##################################################################################
# Github actions currently timeout when downloading musl-gcc #
# Using lldap dev image based on rust:1.62-slim-bullseye and musl-gcc bundled #
# Only for Job build aarch64 and amd64 #
###################################################################################
#image: rust:1.62
image: nitnelave/rust-dev:latest
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps:
- name: Checkout repository
uses: actions/checkout@v3.1.0
- name: smoke test
run: rustc --version
- name: Checkout repository
uses: actions/checkout@v3.1.0
- uses: actions/cache@v3
with:
path: |
.cargo/bin
.cargo/registry/index
.cargo/registry/cache
.cargo/git/db
target
key: lldap-bin-aarch64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-bin-aarch64-
# - name: fetch musl-gcc
# run: |
# wget -c https://musl.cc/aarch64-linux-musl-cross.tgz
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
# echo "/opt/aarch64-linux-musl-cross:/opt/aarch64-linux-musl-cross/bin" >> $GITHUB_PATH
- name: add musl aarch64 target
run: rustup target add aarch64-unknown-linux-musl
- name: build lldap aarch4
run: cargo build --target=aarch64-unknown-linux-musl --release -p lldap -p migration-tool
- name: check path
run: ls -al target/aarch64-unknown-linux-musl/release/
- name: upload aarch64 lldap artifacts
uses: actions/upload-artifact@v3
with:
name: aarch64-lldap-bin
path: target/aarch64-unknown-linux-musl/release/lldap
- name: upload aarch64 migration-tool artifacts
uses: actions/upload-artifact@v3
with:
name: aarch64-migration-tool-bin
path: target/aarch64-unknown-linux-musl/release/migration-tool
build-amd64:
runs-on: ubuntu-latest
container:
# image: rust:1.62
image: nitnelave/rust-dev:latest
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: x86_64-linux-musl-gcc
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps:
- name: Checkout repository
uses: actions/checkout@v3.1.0
uses: actions/checkout@v3.5.0
- uses: actions/cache@v3
with:
path: |
@ -223,82 +147,341 @@ jobs:
.cargo/registry/cache
.cargo/git/db
target
key: lldap-bin-amd64-${{ hashFiles('**/Cargo.lock') }}
key: lldap-bin-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-bin-amd64-
- name: install musl
run: apt update && apt install -y musl-tools tar wget
# - name: fetch musl-gcc
# run: |
# wget -c https://musl.cc/x86_64-linux-musl-cross.tgz
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
# echo "/opt/x86_64-linux-musl-cross:/opt/x86_64-linux-musl-cross/bin" >> $GITHUB_PATH
- name: add x86_64 target
run: rustup target add x86_64-unknown-linux-musl
- name: build x86_64 lldap
run: cargo build --target=x86_64-unknown-linux-musl --release -p lldap -p migration-tool
- name: check path
run: ls -al target/x86_64-unknown-linux-musl/release/
- name: upload amd64 lldap artifacts
lldap-bin-${{ matrix.target }}-
- name: Compile ${{ matrix.target }} lldap and tools
run: cargo build --target=${{ matrix.target }} --release -p lldap -p migration-tool -p lldap_set_password
- name: Check path
run: ls -al target/release
- name: Upload ${{ matrix.target}} lldap artifacts
uses: actions/upload-artifact@v3
with:
name: amd64-lldap-bin
path: target/x86_64-unknown-linux-musl/release/lldap
- name: upload amd64 migration-tool artifacts
name: ${{ matrix.target}}-lldap-bin
path: target/${{ matrix.target }}/release/lldap
- name: Upload ${{ matrix.target }} migration tool artifacts
uses: actions/upload-artifact@v3
with:
name: amd64-migration-tool-bin
path: target/x86_64-unknown-linux-musl/release/migration-tool
name: ${{ matrix.target }}-migration-tool-bin
path: target/${{ matrix.target }}/release/migration-tool
- name: Upload ${{ matrix.target }} password tool artifacts
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.target }}-lldap_set_password-bin
path: target/${{ matrix.target }}/release/lldap_set_password
lldap-database-init-test:
needs: [build-ui,build-bin]
name: LLDAP database init test
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:latest
ports:
- 3306:3306
env:
MARIADB_USER: lldapuser
MARIADB_PASSWORD: lldappass
MARIADB_DATABASE: lldap
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
options: >-
--name mariadb
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
postgresql:
image: postgres:latest
ports:
- 5432:5432
env:
POSTGRES_USER: lldapuser
POSTGRES_PASSWORD: lldappass
POSTGRES_DB: lldap
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
--name postgresql
steps:
- name: Download artifacts
uses: actions/download-artifact@v3
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
- name: Set executables to LLDAP
run: chmod +x bin/lldap
- name: Run lldap with postgres DB and healthcheck
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
LLDAP_ldap_port: 3890
LLDAP_http_port: 17170
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
LLDAP_ldap_port: 3891
LLDAP_http_port: 17171
- name: Run lldap with sqlite DB and healthcheck
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: sqlite://users.db?mode=rwc
LLDAP_ldap_port: 3892
LLDAP_http_port: 17172
- name: Check DB container logs
run: |
docker logs -n 20 mariadb
docker logs -n 20 postgresql
lldap-database-migration-test:
needs: [build-ui,build-bin]
name: LLDAP database migration test
runs-on: ubuntu-latest
services:
postgresql:
image: postgres:latest
ports:
- 5432:5432
env:
POSTGRES_USER: lldapuser
POSTGRES_PASSWORD: lldappass
POSTGRES_DB: lldap
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
--name postgresql
mariadb:
image: mariadb:latest
ports:
- 3306:3306
env:
MARIADB_USER: lldapuser
MARIADB_PASSWORD: lldappass
MARIADB_DATABASE: lldap
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
options: >-
--name mariadb
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
mysql:
image: mysql:latest
ports:
- 3307:3306
env:
MYSQL_USER: lldapuser
MYSQL_PASSWORD: lldappass
MYSQL_DATABASE: lldap
MYSQL_ALLOW_EMPTY_PASSWORD: 1
options: >-
--name mysql
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps:
- name: Download LLDAP artifacts
uses: actions/download-artifact@v3
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
- name: Download LLDAP set password
uses: actions/download-artifact@v3
with:
name: x86_64-unknown-linux-musl-lldap_set_password-bin
path: bin/
- name: Set executables to LLDAP and LLDAP set password
run: |
chmod +x bin/lldap
chmod +x bin/lldap_set_password
- name: Install sqlite3 and ldap-utils for exporting and searching dummy user
run: sudo apt update && sudo apt install -y sqlite3 ldap-utils
- name: Run lldap with sqlite DB and healthcheck
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: sqlite://users.db?mode=rwc
LLDAP_ldap_port: 3890
LLDAP_http_port: 17170
LLDAP_LDAP_USER_PASS: ldappass
LLDAP_JWT_SECRET: somejwtsecret
- name: Create dummy user
run: |
TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "ldappass"}' http://localhost:17170/auth/simple/login | jq -r .token)
echo "$TOKEN"
curl 'http://localhost:17170/api/graphql' -H 'Content-Type: application/json' -H "Authorization: Bearer ${TOKEN//[$'\t\r\n ']}" --data-binary '{"query":"mutation{\n createUser(user:\n {\n id: \"dummyuser\",\n email: \"dummyuser@example.com\"\n }\n )\n {\n id\n email\n }\n}\n\n\n"}' --compressed
bin/lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password ldappass --token $TOKEN --username dummyuser --password dummypassword
- name: Test Dummy User, This will be checked again after importing
run: |
ldapsearch -H ldap://localhost:3890 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
- name: Stop LLDAP sqlite
run: pkill lldap
- name: Export and Converting to Postgress
run: |
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
chmod +x ./helper.sh
./helper.sh | sqlite3 ./users.db > ./dump.sql
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
- name: Create schema on postgres
run: |
bin/lldap create_schema -d postgres://lldapuser:lldappass@localhost:5432/lldap
- name: Copy converted db to postgress and import
run: |
docker ps -a
docker cp ./dump.sql postgresql:/tmp/dump.sql
docker exec postgresql bash -c "psql -U lldapuser -d lldap < /tmp/dump.sql"
rm ./dump.sql
- name: Export and Converting to mariadb
run: |
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
chmod +x ./helper.sh
./helper.sh | sqlite3 ./users.db > ./dump.sql
cp ./dump.sql ./dump-no-sed.sql
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
- name: Create schema on mariadb
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3306/lldap
- name: Copy converted db to mariadb and import
run: |
docker ps -a
docker cp ./dump.sql mariadb:/tmp/dump.sql
docker exec mariadb bash -c "mariadb -ulldapuser -plldappass -f lldap < /tmp/dump.sql"
rm ./dump.sql
- name: Export and Converting to mysql
run: |
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
chmod +x ./helper.sh
./helper.sh | sqlite3 ./users.db > ./dump.sql
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
- name: Create schema on mysql
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3307/lldap
- name: Copy converted db to mysql and import
run: |
docker ps -a
docker cp ./dump.sql mysql:/tmp/dump.sql
docker exec mysql bash -c "mysql -ulldapuser -plldappass -f lldap < /tmp/dump.sql"
rm ./dump.sql
- name: Run lldap with postgres DB and healthcheck again
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: postgres://lldapuser:lldappass@localhost:5432/lldap
LLDAP_ldap_port: 3891
LLDAP_http_port: 17171
LLDAP_LDAP_USER_PASS: ldappass
LLDAP_JWT_SECRET: somejwtsecret
- name: Run lldap with mariaDB and healthcheck again
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3306/lldap
LLDAP_ldap_port: 3892
LLDAP_http_port: 17172
LLDAP_JWT_SECRET: somejwtsecret
- name: Run lldap with mysql and healthcheck again
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3307/lldap
LLDAP_ldap_port: 3893
LLDAP_http_port: 17173
LLDAP_JWT_SECRET: somejwtsecret
- name: Test Dummy User
run: |
ldapsearch -H ldap://localhost:3891 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
ldapsearch -H ldap://localhost:3892 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
ldapsearch -H ldap://localhost:3893 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
build-docker-image:
needs: [build-ui,build-armhf,build-aarch64,build-amd64]
needs: [build-ui, build-bin]
name: Build Docker image
runs-on: ubuntu-latest
strategy:
matrix:
container: ["debian","alpine"]
include:
- container: alpine
platforms: linux/amd64,linux/arm64
tags: |
type=ref,event=pr
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{version}},suffix=
type=semver,pattern=v{{major}},suffix=
type=semver,pattern=v{{major}}.{{minor}},suffix=
type=raw,value=latest,enable={{ is_default_branch }}
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }},suffix=
type=raw,value=latest,enable={{ is_default_branch }},suffix=
- container: debian
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
type=ref,event=pr
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
type=raw,value=latest,enable={{ is_default_branch }}
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
permissions:
contents: read
packages: write
steps:
- name: install rsync
run: sudo apt update && sudo apt install -y rsync
- name: fetch repo
uses: actions/checkout@v3.1.0
- name: Download armhf lldap artifacts
- name: Checkout repository
uses: actions/checkout@v3.5.0
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
name: armhf-lldap-bin
path: bin/armhf-bin
- name: Download armhf migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: armhf-migration-tool-bin
path: bin/armhf-bin
- name: Download aarch64 lldap artifacts
uses: actions/download-artifact@v3
with:
name: aarch64-lldap-bin
path: bin/aarch64-bin
- name: Download aarch64 migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: aarch64-migration-tool-bin
path: bin/aarch64-bin
- name: Download amd64 lldap artifacts
uses: actions/download-artifact@v3
with:
name: amd64-lldap-bin
path: bin/amd64-bin
- name: Download amd64 migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: amd64-migration-tool-bin
path: bin/amd64-bin
- name: check bin path
run: ls -al bin/
path: bin
- name: Download llap ui artifacts
uses: actions/download-artifact@v3
@ -306,102 +489,72 @@ jobs:
name: ui
path: web
- name: setup qemu
- name: Setup QEMU
uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
- name: Docker meta
- name: Docker ${{ matrix.container }} meta
id: meta
uses: docker/metadata-action@v4
with:
# list of Docker images to use as base name for tags
images: |
nitnelave/lldap
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
lldap/lldap
ghcr.io/lldap/lldap
# Wanted Docker tags
# vX-alpine
# vX.Y-alpine
# vX.Y.Z-alpine
# latest
# latest-alpine
# stable
# stable-alpine
#################
# vX-debian
# vX.Y-debian
# vX.Y.Z-debian
# latest-debian
# stable-debian
#################
# Check matrix for tag list definition
flavor: |
latest=false
suffix=-${{ matrix.container }}
tags: ${{ matrix.tags }}
- name: parse tag
uses: gacts/github-slug@v1
id: slug
- name: Login to Docker Hub
# Docker login to nitnelave/lldap and lldap/lldap
- name: Login to Nitnelave/LLDAP Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
######################
#### latest build ####
######################
- name: Build and push latest alpine
if: github.event_name != 'release'
uses: docker/build-push-action@v3
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ghcr.io
username: nitnelave
password: ${{ secrets.GITHUB_TOKEN }}
########################################
#### docker image build ####
########################################
- name: Build ${{ matrix.container }} Docker Image
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
file: ./.github/workflows/Dockerfile.ci.alpine
tags: nitnelave/lldap:latest, nitnelave/lldap:latest-alpine
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Build and push latest debian
if: github.event_name != 'release'
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./.github/workflows/Dockerfile.ci.debian
tags: nitnelave/lldap:latest-debian
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
#######################
#### release build ####
#######################
- name: Build and push release alpine
if: github.event_name == 'release'
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
file: ./.github/workflows/Dockerfile.ci.alpine
tags: nitnelave/lldap:stable, nitnelave/lldap:stable-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine.${{ steps.slug.outputs.version-minor }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-alpine
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Build and push release debian
if: github.event_name == 'release'
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
file: ./.github/workflows/Dockerfile.ci.debian
tags: nitnelave/lldap:stable-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-debian
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Move cache
run: rsync -r /tmp/.buildx-cache-new /tmp/.buildx-cache --delete
platforms: ${{ matrix.platforms }}
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }}
tags: |
${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,mode=max
cache-to: type=gha,mode=max
- name: Update repo description
if: github.event_name != 'pull_request'
@ -411,3 +564,96 @@ jobs:
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: nitnelave/lldap
- name: Update lldap repo description
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: lldap/lldap
###############################################################
### Download artifacts, clean up ui, upload to release page ###
###############################################################
create-release-artifacts:
needs: [build-ui, build-bin]
name: Create release artifacts
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: bin/
- name: Check file
run: ls -alR bin/
- name: Fixing Filename
run: |
mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap bin/aarch64-lldap
mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap bin/amd64-lldap
mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap bin/armhf-lldap
mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool bin/aarch64-migration-tool
mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool bin/amd64-migration-tool
mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool bin/armhf-migration-tool
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/aarch64-lldap_set_password
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/amd64-lldap_set_password
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
chmod +x bin/*-lldap
chmod +x bin/*-migration-tool
chmod +x bin/*-lldap_set_password
- name: Download llap ui artifacts
uses: actions/download-artifact@v3
with:
name: ui
path: web
- name: UI (web) artifacts cleanup
run: mkdir app && mv web/index.html app/index.html && mv web/static app/static && mv web/pkg app/pkg
- name: Fetch web components
run: |
sudo apt update
sudo apt install wget
for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done
for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done
chmod a+r -R .
- name: Setup LLDAP dir for packing
run: |
mkdir aarch64-lldap
mkdir amd64-lldap
mkdir armhf-lldap
mv bin/aarch64-lldap aarch64-lldap/lldap
mv bin/amd64-lldap amd64-lldap/lldap
mv bin/armhf-lldap armhf-lldap/lldap
mv bin/aarch64-migration-tool aarch64-lldap/migration-tool
mv bin/amd64-migration-tool amd64-lldap/migration-tool
mv bin/armhf-migration-tool armhf-lldap/migration-tool
mv bin/aarch64-lldap_set_password aarch64-lldap/lldap_set_password
mv bin/amd64-lldap_set_password amd64-lldap/lldap_set_password
mv bin/armhf-lldap_set_password armhf-lldap/lldap_set_password
cp -r app aarch64-lldap/
cp -r app amd64-lldap/
cp -r app armhf-lldap/
ls -alR aarch64-lldap/
ls -alR amd64-lldap/
ls -alR armhf-lldap/
- name: Packing LLDAP and Web UI
run: |
tar -czvf aarch64-lldap.tar.gz aarch64-lldap/
tar -czvf amd64-lldap.tar.gz amd64-lldap/
tar -czvf armhf-lldap.tar.gz armhf-lldap/
- name: Upload compressed release
uses: ncipollo/release-action@v1
id: create_release
with:
allowUpdates: true
artifacts: aarch64-lldap.tar.gz,
amd64-lldap.tar.gz,
armhf-lldap.tar.gz
env:
GITHUB_TOKEN: ${{ github.token }}

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@v3.5.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@v3.5.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@v3.5.0
- uses: Swatinem/rust-cache@v1
- uses: Swatinem/rust-cache@v2
- name: Run cargo fmt
uses: actions-rs/cargo@v1
@ -87,14 +86,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3.1.0
uses: actions/checkout@v3.5.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
@ -102,6 +101,14 @@ jobs:
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
with:
files: lcov.info
fail_ci_if_error: true
- name: Upload coverage to Codecov (main)
uses: codecov/codecov-action@v3
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
with:
files: lcov.info
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

1
.gitignore vendored
View File

@ -23,6 +23,7 @@ server_key
*.tar.gz
# Misc
.vscode
.env
recipe.json
lldap_config.toml

View File

@ -5,7 +5,95 @@ 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.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

2478
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,21 @@ members = [
"server",
"auth",
"app",
"migration-tool"
"migration-tool",
"set-password",
]
default-members = ["server"]
# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged.
[patch.crates-io.ldap3_proto]
git = 'https://github.com/nitnelave/ldap3_server/'
rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc'
[profile.release]
lto = true
[profile.release.package.lldap_app]
opt-level = 's'
[patch.crates-io.opaque-ke]
git = 'https://github.com/nitnelave/opaque-ke/'
branch = 'zeroize_1.5'
[patch.crates-io.lber]
git = 'https://github.com/inejge/ldap3/'

View File

@ -1,5 +1,5 @@
# Build image
FROM rust:alpine3.14 AS chef
FROM rust:alpine3.16 AS chef
RUN set -x \
# Add user
@ -11,7 +11,7 @@ RUN set -x \
--uid 10001 \
app \
# Install required packages
&& apk add npm openssl-dev musl-dev make perl curl
&& apk add openssl-dev musl-dev make perl curl gzip
USER app
WORKDIR /app
@ -19,7 +19,6 @@ WORKDIR /app
RUN set -x \
# Install build tools
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
&& npm install rollup \
&& rustup target add wasm32-unknown-unknown
# Prepare the dependency list.
@ -32,27 +31,58 @@ FROM chef AS builder
COPY --from=planner /tmp/recipe.json recipe.json
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
&& cargo chef cook --release -p lldap \
&& cargo chef cook --release -p migration-tool
&& cargo chef cook --release -p 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 migration-tool -p lldap_set_password \
# Build the frontend.
&& ./app/build.sh
# Final image
FROM alpine:3.14
FROM alpine:3.16
ENV GOSU_VERSION 1.14
# Fetch gosu from git
RUN set -eux; \
\
apk add --no-cache --virtual .gosu-deps \
ca-certificates \
dpkg \
gnupg \
; \
\
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
\
# verify the signature
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
command -v gpgconf && gpgconf --kill all || :; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
\
# clean up fetch dependencies
apk del --no-network .gosu-deps; \
\
chmod +x /usr/local/bin/gosu; \
# verify that the binary works
gosu --version; \
gosu nobody true
WORKDIR /app
COPY --from=builder /app/app/index_local.html app/index.html
COPY --from=builder /app/app/static app/static
COPY --from=builder /app/app/pkg app/pkg
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool ./
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool /app/target/release/lldap_set_password ./
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
RUN set -x \
&& apk add --no-cache bash \
&& apk add --no-cache bash tzdata \
&& for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done \
&& for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R .
@ -64,3 +94,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]
HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]

259
README.md
View File

@ -23,25 +23,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>
</p>
- [About](#About)
- [Installation](#Installation)
- [With Docker](#With-Docker)
- [From source](#From-source)
- [Cross-compilation](#Cross-compilation)
- [Client configuration](#Client-configuration)
- [Compatible services](#compatible-services)
- [General configuration guide](#general-configuration-guide)
- [Sample client configurations](#Sample-client-configurations)
- [Comparisons with other services](#Comparisons-with-other-services)
- [vs OpenLDAP](#vs-openldap)
- [vs FreeIPA](#vs-freeipa)
- [I can't log in!](#i-cant-log-in)
- [Contributions](#Contributions)
- [About](#about)
- [Installation](#installation)
- [With Docker](#with-docker)
- [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
@ -62,10 +62,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,6 +77,9 @@ 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
@ -97,11 +101,15 @@ 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.
Example for docker compose for `:stable` tag:
* When defined with `user: ##:##` , ensure `/data` directory had permission for the defined user, else `1000:1000` used.
Example for docker compose:
- You can use either the `:latest` tag image or `:stable` as used in this example.
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
- If no `TZ` is set, default `UTC` timezone will be used.
```yaml
version: '3'
version: "3"
volumes:
lldap_data:
@ -110,37 +118,6 @@ volumes:
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
```
Example for docker compose for `:latest` tag:
* `:latest` tag image contain recent pushed codes or feature test, breaks is expected.
* If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`
```yaml
version: '3'
volumes:
lldap_data:
driver: local
services:
lldap:
image: nitnelave/lldap:latest
ports:
# For LDAP
- "3890:3890"
@ -153,20 +130,30 @@ services:
environment:
- UID=####
- GID=####
- TZ=####/####
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
# You can also set a different database:
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
```
Then the service will listen on two ports, one for LDAP and one for the web
front-end.
### With Kubernetes
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
### From source
#### Backend
To compile the project, you'll need:
* npm, curl: `sudo apt install curl npm`
* Rust/Cargo: [rustup.rs](https://rustup.rs/)
- curl and gzip: `sudo apt install curl gzip`
- Rust/Cargo: [rustup.rs](https://rustup.rs/)
Then you can compile the server (and the migration tool if you want):
@ -177,14 +164,21 @@ cargo build --release -p lldap -p migration-tool
The resulting binaries will be in `./target/release/`. Alternatively, you can
just run `cargo run -- run` to run the server.
#### Frontend
To bring up the server, you'll need to compile the frontend. In addition to
cargo, you'll need:
`cargo`, you'll need:
* WASM-pack: `cargo install wasm-pack`
* rollup.js: `npm install rollup`
- WASM-pack: `cargo install wasm-pack`
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).
Then you can build the frontend files with
```shell
./app/build.sh
````
(you'll need to run this after every front-end change to update the WASM
package served).
The default config is in `src/infra/configuration.rs`, but you can override it
by creating an `lldap_config.toml`, setting environment variables or passing
@ -229,14 +223,15 @@ the config).
### General configuration guide
To configure the services that will talk to LLDAP, here are the values:
- The LDAP user DN is from the configuration. By default,
`cn=admin,ou=people,dc=example,dc=com`.
- The LDAP password is from the configuration (same as to log in to the web
UI).
- The users are all located in `ou=people,` + the base DN, so by default user
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
- Similarly, the groups are located in `ou=groups`, so the group `family`
will be at `cn=family,ou=groups,dc=example,dc=com`.
- The LDAP user DN is from the configuration. By default,
`cn=admin,ou=people,dc=example,dc=com`.
- The LDAP password is from the configuration (same as to log in to the web
UI).
- The users are all located in `ou=people,` + the base DN, so by default user
`bob` is at `cn=bob,ou=people,dc=example,dc=com`.
- Similarly, the groups are located in `ou=groups`, so the group `family`
will be at `cn=family,ou=groups,dc=example,dc=com`.
Testing group membership through `memberOf` is supported, so you can have a
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
@ -251,53 +246,71 @@ administration access to many services.
Some specific clients have been tested to work and come with sample
configuration files, or guides. See the [`example_configs`](example_configs)
folder for help with:
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
- [Apache Guacamole](example_configs/apacheguacamole.md)
- [Authelia](example_configs/authelia_config.yml)
- [Bookstack](example_configs/bookstack.env.example)
- [Calibre-Web](example_configs/calibre_web.md)
- [Dokuwiki](example_configs/dokuwiki.md)
- [Dolibarr](example_configs/dolibarr.md)
- [Emby](example_configs/emby.md)
- [Gitea](example_configs/gitea.md)
- [Grafana](example_configs/grafana_ldap_config.toml)
- [Hedgedoc](example_configs/hedgedoc.md)
- [Jellyfin](example_configs/jellyfin.md)
- [Jitsi Meet](example_configs/jitsi_meet.conf)
- [KeyCloak](example_configs/keycloak.md)
- [Matrix](example_configs/matrix_synapse.yml)
- [Nextcloud](example_configs/nextcloud.md)
- [Organizr](example_configs/Organizr.md)
- [Portainer](example_configs/portainer.md)
- [Seafile](example_configs/seafile.md)
- [Syncthing](example_configs/syncthing.md)
- [WG Portal](example_configs/wg_portal.env.example)
- [XBackBone](example_configs/xbackbone_config.php)
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
- [Apache Guacamole](example_configs/apacheguacamole.md)
- [Authelia](example_configs/authelia_config.yml)
- [Authentik](example_configs/authentik.md)
- [Bookstack](example_configs/bookstack.env.example)
- [Calibre-Web](example_configs/calibre_web.md)
- [Dell iDRAC](example_configs/dell_idrac.md)
- [Dex](example_configs/dex_config.yml)
- [Dokuwiki](example_configs/dokuwiki.md)
- [Dolibarr](example_configs/dolibarr.md)
- [Emby](example_configs/emby.md)
- [Gitea](example_configs/gitea.md)
- [Grafana](example_configs/grafana_ldap_config.toml)
- [Hedgedoc](example_configs/hedgedoc.md)
- [Jellyfin](example_configs/jellyfin.md)
- [Jitsi Meet](example_configs/jitsi_meet.conf)
- [KeyCloak](example_configs/keycloak.md)
- [Matrix](example_configs/matrix_synapse.yml)
- [Nextcloud](example_configs/nextcloud.md)
- [Nexus](example_configs/nexus.md)
- [Organizr](example_configs/Organizr.md)
- [Portainer](example_configs/portainer.md)
- [Rancher](example_configs/rancher.md)
- [Seafile](example_configs/seafile.md)
- [Shaarli](example_configs/shaarli.md)
- [Syncthing](example_configs/syncthing.md)
- [Vaultwarden](example_configs/vaultwarden.md)
- [WeKan](example_configs/wekan.md)
- [WG Portal](example_configs/wg_portal.env.example)
- [WikiJS](example_configs/wikijs.md)
- [XBackBone](example_configs/xbackbone_config.php)
- [Zendto](example_configs/zendto.md)
## Migrating from SQLite
If you started with an SQLite database and would like to migrate to
MySQL/MariaDB or PostgreSQL, check out the [DB
migration docs](/docs/database_migration.md).
## Comparisons with other services
### vs OpenLDAP
OpenLDAP is a monster of a service that implements all of LDAP and all of its
extensions, plus some of its own. That said, if you need all that flexibility,
it might be what you need! Note that installation can be a bit painful
(figuring out how to use `slapd`) and people have mixed experiences following
tutorials online. If you don't configure it properly, you might end up storing
passwords in clear, so a breach of your server would reveal all the stored
passwords!
[OpenLDAP](https://www.openldap.org) is a monster of a service that implements
all of LDAP and all of its extensions, plus some of its own. That said, if you
need all that flexibility, it might be what you need! Note that installation
can be a bit painful (figuring out how to use `slapd`) and people have mixed
experiences following tutorials online. If you don't configure it properly, you
might end up storing passwords in clear, so a breach of your server would
reveal all the stored passwords!
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
install one (not that many that look nice) and configure it.
install one (not that many look nice) and configure it.
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
However, it's not as flexible as OpenLDAP.
### vs FreeIPA
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
Samba, you name it, it has it. In addition to user management, it also does
security policies, single sign-on, certificate management, linux account
management and so on.
[FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
management, it also does security policies, single sign-on, certificate
management, linux account management and so on.
If you need all of that, go for it! Keep in mind that a more complex system is
more complex to maintain, though.
@ -306,25 +319,37 @@ LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
configure (no messing around with DNS or security policies) and simpler to
use. It also comes conveniently packed in a docker container.
### vs Kanidm
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
WebAuthn. It comes with a (read-only) LDAPS server.
It's fairly easy to install and does much more; but their LDAP server is
read-only, and by having more moving parts it is inherently more complex. If
you don't need to modify the users through LDAP and you're planning on
installing something like [KeyCloak](https://www.keycloak.org) to provide
modern identity protocols, check out Kanidm.
## I can't log in!
If you just set up the server, can get to the login page but the password you
set isn't working, try the following:
- (For docker): Make sure that the `/data` folder is persistent, either to a
docker volume or mounted from the host filesystem.
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
or in the current directory). If there isn't, copy
`lldap_config.docker_template.toml` there, and fill in the various values
(passwords, secrets, ...).
- Check if there is a `users.db` file (either in `/data` for docker or where
you specified the DB URL, which defaults to the current directory). If
there isn't, check that the user running the command (user with ID 10001
for docker) has the rights to write to the `/data` folder. If in doubt, you
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
- Make sure you restart the server.
- If it's still not working, join the
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
- (For docker): Make sure that the `/data` folder is persistent, either to a
docker volume or mounted from the host filesystem.
- Check if there is a `lldap_config.toml` file (either in `/data` for docker
or in the current directory). If there isn't, copy
`lldap_config.docker_template.toml` there, and fill in the various values
(passwords, secrets, ...).
- Check if there is a `users.db` file (either in `/data` for docker or where
you specified the DB URL, which defaults to the current directory). If
there isn't, check that the user running the command (user with ID 10001
for docker) has the rights to write to the `/data` folder. If in doubt, you
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
- Make sure you restart the server.
- If it's still not working, join the
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
## Contributions

View File

@ -1,24 +1,29 @@
[package]
name = "lldap_app"
version = "0.4.1"
version = "0.4.4-alpha"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
[dependencies]
anyhow = "1"
base64 = "0.13"
gloo-console = "0.2.3"
gloo-file = "0.2.3"
gloo-net = "*"
graphql_client = "0.10"
http = "0.2"
jwt = "0.13"
rand = "0.8"
serde = "1"
serde_json = "1"
url-escape = "0.1.1"
validator = "=0.14"
validator_derive = "*"
wasm-bindgen = "0.2"
yew = "0.18"
yewtil = "*"
yew-router = "0.15"
wasm-bindgen-futures = "*"
yew = "0.19.3"
yew-router = "0.16"
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
indexmap = "=1.6.2"
@ -54,11 +59,11 @@ version = "0.24"
[dependencies.yew_form]
git = "https://github.com/jfbilodeau/yew_form"
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
[dependencies.yew_form_derive]
git = "https://github.com/jfbilodeau/yew_form"
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
[lib]
crate-type = ["cdylib"]

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

View File

@ -4,17 +4,21 @@
<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="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"
@ -30,6 +34,11 @@
<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>

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,6 +31,11 @@
<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>

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()

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,39 @@ impl Component for AddGroupMemberComponent {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut res = Self {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
user_list: None,
selected_user: None,
};
res.get_user_list();
res.get_user_list(ctx);
res
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error(
self,
ctx,
msg,
self.common.on_error.clone(),
ctx.props().on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
if let Some(user_list) = &self.user_list {
let to_add_user_list = self.get_selectable_user_list(user_list);
let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
#[allow(unused_braces)]
let make_select_option = |user: User| {
html_nested! {
<SelectOption value=user.id.clone() text=user.display_name.clone() key=user.id />
<SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
}
};
html! {
<div class="row">
<div class="col-sm-3">
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
<Select on_selection_change={link.callback(Msg::SelectionChanged)}>
{
to_add_user_list
.into_iter()
@ -169,12 +171,13 @@ impl Component for AddGroupMemberComponent {
}
</Select>
</div>
<div class="col-sm-1">
<div class="col-3">
<button
class="btn btn-success"
disabled=self.selected_user.is_none() || self.common.is_task_running()
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
{"Add"}
class="btn btn-secondary"
disabled={self.selected_user.is_none() || self.common.is_task_running()}
onclick={link.callback(|_| Msg::SubmitAddMember)}>
<i class="bi-person-plus me-2"></i>
{"Add to group"}
</button>
</div>
</div>

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>

View File

@ -9,161 +9,208 @@ use crate::{
logout::LogoutButton,
reset_password_step1::ResetPasswordStep1Form,
reset_password_step2::ResetPasswordStep2Form,
router::{AppRoute, Link, NavButton},
router::{AppRoute, Link, Redirect},
user_details::UserDetails,
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 wasm_bindgen::prelude::*;
use yew::{
function_component,
html::Scope,
prelude::{html, Component, Html},
Context,
};
use yew_router::{
prelude::{History, Location},
scope_ext::RouterScopeExt,
BrowserRouter, Switch,
};
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = darkmode)]
fn toggleDarkMode(doSave: bool);
#[wasm_bindgen]
fn inDarkMode() -> bool;
}
#[function_component(DarkModeToggle)]
pub fn dark_mode_toggle() -> Html {
html! {
<div class="form-check form-switch">
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
</div>
}
}
#[function_component(AppContainer)]
pub fn app_container() -> Html {
html! {
<BrowserRouter>
<App />
</BrowserRouter>
}
}
pub struct App {
link: ComponentLink<Self>,
user_info: Option<(String, bool)>,
redirect_to: Option<AppRoute>,
route_dispatcher: RouteAgentDispatcher,
password_reset_enabled: Option<bool>,
}
pub enum Msg {
Login((String, bool)),
Logout,
PasswordResetProbeFinished(anyhow::Result<bool>),
}
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut app = Self {
link,
fn create(ctx: &Context<Self>) -> Self {
let app = Self {
user_info: get_cookie("user_id")
.unwrap_or_else(|e| {
ConsoleService::error(&e.to_string());
error!(&e.to_string());
None
})
.and_then(|u| {
get_cookie("is_admin")
.map(|so| so.map(|s| (u, s == "true")))
.unwrap_or_else(|e| {
ConsoleService::error(&e.to_string());
error!(&e.to_string());
None
})
}),
redirect_to: Self::get_redirect_route(),
route_dispatcher: RouteAgentDispatcher::new(),
redirect_to: Self::get_redirect_route(ctx),
password_reset_enabled: None,
};
app.apply_initial_redirections();
ctx.link().send_future(async move {
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
});
app.apply_initial_redirections(ctx);
app
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
let history = ctx.link().history().unwrap();
match msg {
Msg::Login((user_name, is_admin)) => {
self.user_info = Some((user_name.clone(), is_admin));
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(
self.redirect_to.take().unwrap_or_else(|| {
if is_admin {
AppRoute::ListUsers
} else {
AppRoute::UserDetails(user_name.clone())
}
}),
)));
history.push(self.redirect_to.take().unwrap_or_else(|| {
if is_admin {
AppRoute::ListUsers
} else {
AppRoute::UserDetails {
user_id: user_name.clone(),
}
}
}));
}
Msg::Logout => {
self.user_info = None;
self.redirect_to = None;
history.push(AppRoute::Login);
}
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
self.password_reset_enabled = Some(enabled);
}
Msg::PasswordResetProbeFinished(Err(err)) => {
self.password_reset_enabled = Some(false);
error!(&format!(
"Could not probe for password reset support: {err:#}"
));
}
}
if self.user_info.is_none() {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
}
true
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
}
fn view(&self) -> Html {
let link = self.link.clone();
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link().clone();
let is_admin = self.is_admin();
let password_reset_enabled = self.password_reset_enabled;
html! {
<div class="container shadow-sm py-3">
{self.view_banner()}
<div>
{self.view_banner(ctx)}
<div class="container py-3 bg-kug">
<div class="row justify-content-center" style="padding-bottom: 80px;">
<div class="shadow-sm py-3" style="max-width: 1000px">
<Router<AppRoute>
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
<main class="py-3" style="max-width: 1000px">
<Switch<AppRoute>
render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
/>
</div>
</main>
</div>
{self.view_footer()}
</div>
</div>
}
}
}
impl App {
fn get_redirect_route() -> Option<AppRoute> {
let route_service = RouteService::<()>::new();
let current_route = route_service.get_path();
if current_route.is_empty()
|| current_route == "/"
|| current_route.contains("login")
|| current_route.contains("reset-password")
{
None
} else {
use yew_router::Switch;
AppRoute::from_route_part::<()>(current_route, None).0
}
// Get the page to land on after logging in, defaulting to the index.
fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
let route = ctx.link().history().unwrap().location().route::<AppRoute>();
route.filter(|route| {
!matches!(
route,
AppRoute::Index
| AppRoute::Login
| AppRoute::StartResetPassword
| AppRoute::FinishResetPassword { token: _ }
)
})
}
fn apply_initial_redirections(&mut self) {
let route_service = RouteService::<()>::new();
let current_route = route_service.get_path();
if current_route.contains("reset-password") {
return;
}
match &self.user_info {
None => {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
fn apply_initial_redirections(&self, ctx: &Context<Self>) {
let history = ctx.link().history().unwrap();
let route = history.location().route::<AppRoute>();
let redirection = match (route, &self.user_info, &self.redirect_to) {
(
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
_,
_,
) => {
if self.password_reset_enabled == Some(false) {
Some(AppRoute::Login)
} else {
None
}
}
Some((user_name, is_admin)) => match &self.redirect_to {
Some(url) => {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
// User is logged in, a URL was given, don't redirect.
(_, Some(_), Some(_)) => None,
(_, Some((user_name, is_admin)), None) => {
if *is_admin {
Some(AppRoute::ListUsers)
} else {
Some(AppRoute::UserDetails {
user_id: user_name.clone(),
})
}
None => {
if *is_admin {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
} else {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(
AppRoute::UserDetails(user_name.clone()),
)));
}
}
},
}
};
if let Some(redirect_to) = redirection {
history.push(redirect_to);
}
}
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
fn dispatch_route(
switch: &AppRoute,
link: &Scope<Self>,
is_admin: bool,
password_reset_enabled: Option<bool>,
) -> Html {
match switch {
AppRoute::Login => html! {
<LoginForm on_logged_in=link.callback(Msg::Login)/>
<LoginForm on_logged_in={link.callback(Msg::Login)} password_reset_enabled={password_reset_enabled.unwrap_or(false)}/>
},
AppRoute::CreateUser => html! {
<CreateUserForm/>
@ -171,7 +218,10 @@ impl App {
AppRoute::Index | AppRoute::ListUsers => html! {
<div>
<UserTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
<Link classes="btn btn-primary" to={AppRoute::CreateUser}>
<i class="bi-person-plus me-2"></i>
{"Create a user"}
</Link>
</div>
},
AppRoute::CreateGroup => html! {
@ -180,34 +230,46 @@ impl App {
AppRoute::ListGroups => html! {
<div>
<GroupTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
<Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
<i class="bi-plus-circle me-2"></i>
{"Create a group"}
</Link>
</div>
},
AppRoute::GroupDetails(group_id) => html! {
<GroupDetails group_id=group_id />
AppRoute::GroupDetails { group_id } => html! {
<GroupDetails group_id={*group_id} />
},
AppRoute::UserDetails(username) => html! {
<UserDetails username=username is_admin=is_admin />
AppRoute::UserDetails { user_id } => html! {
<UserDetails username={user_id.clone()} is_admin={is_admin} />
},
AppRoute::ChangePassword(username) => html! {
<ChangePasswordForm username=username is_admin=is_admin />
AppRoute::ChangePassword { user_id } => html! {
<ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
},
AppRoute::StartResetPassword => html! {
<ResetPasswordStep1Form />
AppRoute::StartResetPassword => match password_reset_enabled {
Some(true) => html! { <ResetPasswordStep1Form /> },
Some(false) => {
html! { <Redirect to={AppRoute::Login}/> }
}
None => html! {},
},
AppRoute::FinishResetPassword(token) => html! {
<ResetPasswordStep2Form token=token />
AppRoute::FinishResetPassword { token } => match password_reset_enabled {
Some(true) => html! { <ResetPasswordStep2Form token={token.clone()} /> },
Some(false) => {
html! { <Redirect to={AppRoute::Login}/> }
}
None => html! {},
},
}
}
fn view_banner(&self) -> Html {
fn view_banner(&self, ctx: &Context<Self>) -> Html {
html! {
<header class="p-3 mb-4 border-bottom shadow-sm">
<header class="p-2 mb-3 border-bottom">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 me-md-5 text-dark text-decoration-none">
<h1>{"LLDAP"}</h1>
<a href="/" class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
<h2>{"LLDAP"}</h2>
</a>
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
@ -215,66 +277,80 @@ impl App {
<>
<li>
<Link
classes="nav-link px-2 link-dark h4"
route=AppRoute::ListUsers>
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 link-dark h4"
route=AppRoute::ListGroups>
classes="nav-link px-2 h6"
to={AppRoute::ListGroups}>
<i class="bi-collection me-2"></i>
{"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>
{ self.view_user_menu(ctx) }
<DarkModeToggle />
</div>
</div>
</header>
}
}
fn view_user_menu(&self, ctx: &Context<Self>) -> Html {
if let Some((user_id, _)) = &self.user_info {
let link = ctx.link();
html! {
<div class="dropdown text-end">
<a href="#"
class="d-block nav-link text-decoration-none dropdown-toggle"
id="dropdownUser"
data-bs-toggle="dropdown"
aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="currentColor"
class="bi bi-person-circle"
viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
<span class="ms-2">
{user_id}
</span>
</a>
<ul
class="dropdown-menu text-small dropdown-menu-lg-end"
aria-labelledby="dropdownUser1"
style="">
<li>
<Link
classes="dropdown-item"
to={AppRoute::UserDetails{ user_id: user_id.clone() }}>
{"View details"}
</Link>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<LogoutButton on_logged_out={link.callback(|_| Msg::Logout)} />
</li>
</ul>
</div>
}
} else {
html! {}
}
}
fn view_footer(&self) -> Html {
html! {
<footer class="text-center 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>

View File

@ -1,34 +1,27 @@
use crate::{
components::router::{AppRoute, NavButton},
components::router::{AppRoute, Link},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{anyhow, bail, Context, Result};
use anyhow::{anyhow, bail, Result};
use gloo_console::error;
use lldap_auth::*;
use validator_derive::Validate;
use yew::{prelude::*, services::ConsoleService};
use yew::prelude::*;
use yew_form::Form;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(PartialEq, Eq)]
#[derive(PartialEq, Eq, Default)]
enum OpaqueData {
#[default]
None,
Login(opaque::client::login::ClientLogin),
Registration(opaque::client::registration::ClientRegistration),
}
impl Default for OpaqueData {
fn default() -> Self {
OpaqueData::None
}
}
impl OpaqueData {
fn take(&mut self) -> Self {
std::mem::take(self)
@ -61,7 +54,6 @@ pub struct ChangePasswordForm {
common: CommonComponentParts<Self>,
form: Form<FormModel>,
opaque_data: OpaqueData,
route_dispatcher: RouteAgentDispatcher,
}
#[derive(Clone, PartialEq, Eq, Properties)]
@ -80,15 +72,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 +97,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(),
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,17 +116,14 @@ 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;
@ -138,15 +132,15 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
opaque::client::registration::start_registration(&new_password, &mut rng)
.context("Could not initiate password change")?;
let req = registration::ClientRegistrationStartRequest {
username: self.common.username.clone(),
username: ctx.props().username.clone(),
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 +160,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,28 +190,38 @@ 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;
fn view(&self, ctx: &Context<Self>) -> Html {
let is_admin = ctx.props().is_admin;
let link = ctx.link();
type Field = yew_form::Field<FormModel>;
html! {
<>
<div class="mb-2 mt-2">
<h5 class="fw-bold">
{"Change password"}
</h5>
</div>
{
if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger mt-3 mb-3">
{e.to_string() }
</div>
}
} else { html! {} }
}
<form
class="form">
{if !is_admin { html! {
@ -230,84 +232,81 @@ impl Component for ChangePasswordForm {
</label>
<div class="col-sm-10">
<Field
form=&self.form
form={&self.form}
field_name="old_password"
input_type="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="current-password"
oninput=self.common.callback(|_| Msg::FormUpdate) />
oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback">
{&self.form.field_message("old_password")}
</div>
</div>
</div>
}} else { html! {} }}
<div class="form-group row">
<div class="form-group row mb-3">
<label for="new_password"
class="form-label col-sm-2 col-form-label">
{"New password*:"}
{"New Password"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-sm-10">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback">
{&self.form.field_message("password")}
</div>
</div>
</div>
<div class="form-group row">
<div class="form-group row mb-3">
<label for="confirm_password"
class="form-label col-sm-2 col-form-label">
{"Confirm password*:"}
{"Confirm Password"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-sm-10">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback">
{&self.form.field_message("confirm_password")}
</div>
</div>
</div>
<div class="form-group row">
<div class="form-group row justify-content-center">
<button
class="btn btn-primary col-sm-1 col-form-label"
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::Submit})>
{"Submit"}
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
<i class="bi-save me-2"></i>
{"Save changes"}
</button>
<Link
classes="btn btn-secondary ms-2 col-auto col-form-label"
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
<i class="bi-arrow-return-left me-2"></i>
{"Back"}
</Link>
</div>
</form>
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
<div>
<NavButton
classes="btn btn-primary"
route=AppRoute::UserDetails(self.common.username.clone())>
{"Back"}
</NavButton>
</div>
</>
}
}

View File

@ -3,15 +3,12 @@ use crate::{
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use yew::prelude::*;
use yew::services::ConsoleService;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(GraphQLQuery)]
#[graphql(
@ -24,7 +21,6 @@ pub struct CreateGroup;
pub struct CreateGroupForm {
common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateGroupModel>,
}
@ -41,7 +37,11 @@ pub enum Msg {
}
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitForm => {
@ -53,6 +53,7 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
name: model.groupname,
};
self.common.call_graphql::<CreateGroup, _>(
ctx,
req,
Msg::CreateGroupResponse,
"Error trying to create group",
@ -60,12 +61,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
Ok(true)
}
Msg::CreateGroupResponse(response) => {
ConsoleService::log(&format!(
log!(&format!(
"Created group '{}'",
&response?.create_group.display_name
));
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
ctx.link().history().unwrap().push(AppRoute::ListGroups);
Ok(true)
}
}
@ -80,44 +80,42 @@ impl Component for CreateGroupForm {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(props, link),
route_dispatcher: RouteAgentDispatcher::new(),
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
type Field = yew_form::Field<CreateGroupModel>;
html! {
<div class="row justify-content-center">
<form class="form shadow-sm py-3" style="max-width: 636px">
<form class="form py-3" style="max-width: 636px">
<div class="row mb-3">
<h5 class="fw-bold">{"Create a group"}</h5>
</div>
<div class="form-group row mb-3">
<label for="groupname"
class="form-label col-4 col-form-label">
{"Group name*:"}
{"Group name"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-8">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("groupname")}
</div>
@ -127,8 +125,9 @@ impl Component for CreateGroupForm {
<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})>
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
<i class="bi-save me-2"></i>
{"Submit"}
</button>
</div>

View File

@ -5,17 +5,14 @@ use crate::{
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Context, Result};
use anyhow::{bail, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use lldap_auth::{opaque, registration};
use validator_derive::Validate;
use yew::prelude::*;
use yew::services::ConsoleService;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
#[derive(GraphQLQuery)]
#[graphql(
@ -28,7 +25,6 @@ pub struct CreateUser;
pub struct CreateUserForm {
common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateUserModel>,
}
@ -38,7 +34,6 @@ pub struct CreateUserModel {
username: String,
#[validate(email(message = "A valid email is required"))]
email: String,
#[validate(length(min = 1, message = "Display name is required"))]
display_name: String,
first_name: String,
last_name: String,
@ -74,7 +69,11 @@ pub enum Msg {
}
impl CommonComponent<CreateUserForm> for CreateUserForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitForm => {
@ -94,6 +93,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
},
};
self.common.call_graphql::<CreateUser, _>(
ctx,
req,
Msg::CreateUserResponse,
"Error trying to create user",
@ -103,7 +103,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
)),
@ -123,12 +123,11 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
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 +143,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,44 +170,42 @@ impl Component for CreateUserForm {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(props, link),
route_dispatcher: RouteAgentDispatcher::new(),
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
type Field = yew_form::Field<CreateUserModel>;
html! {
<div class="row justify-content-center">
<form class="form shadow-sm py-3" style="max-width: 636px">
<form class="form py-3" style="max-width: 636px">
<div class="row mb-3">
<h5 class="fw-bold">{"Create a user"}</h5>
</div>
<div class="form-group row mb-3">
<label for="username"
class="form-label col-4 col-form-label">
{"User name*:"}
{"User name"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-8">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("username")}
</div>
@ -220,18 +214,20 @@ impl Component for CreateUserForm {
<div class="form-group row mb-3">
<label for="email"
class="form-label col-4 col-form-label">
{"Email*:"}
{"Email"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-8">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("email")}
</div>
@ -240,17 +236,17 @@ impl Component for CreateUserForm {
<div class="form-group row mb-3">
<label for="display-name"
class="form-label col-4 col-form-label">
{"Display name*:"}
{"Display name:"}
</label>
<div class="col-8">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("display_name")}
</div>
@ -263,13 +259,13 @@ impl Component for CreateUserForm {
</label>
<div class="col-8">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("first_name")}
</div>
@ -282,13 +278,13 @@ impl Component for CreateUserForm {
</label>
<div class="col-8">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("last_name")}
</div>
@ -301,14 +297,14 @@ impl Component for CreateUserForm {
</label>
<div class="col-8">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("password")}
</div>
@ -321,14 +317,14 @@ impl Component for CreateUserForm {
</label>
<div class="col-8">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("confirm_password")}
</div>
@ -337,14 +333,16 @@ impl Component for CreateUserForm {
<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()
disabled={self.common.is_task_running()}
type="submit"
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
<i class="bi-save me-2"></i>
{"Submit"}
</button>
</div>
</form>
{ if let Some(e) = &self.common.error {
{
if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }

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>

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>

View File

@ -46,10 +46,11 @@ pub struct Props {
}
impl GroupDetails {
fn get_group_details(&mut self) {
fn get_group_details(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<GetGroupDetails, _>(
ctx,
get_group_details::Variables {
id: self.common.group_id,
id: ctx.props().group_id,
},
Msg::GroupDetailsResponse,
"Error trying to fetch group details",
@ -89,7 +90,7 @@ impl GroupDetails {
{"Creation date: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-constrol-static">{g.creation_date.date().naive_local()}</span>
<span id="creationDate" class="form-constrol-static">{g.creation_date.naive_local().date()}</span>
</div>
</div>
<div class="form-group row mb-3">
@ -107,24 +108,25 @@ impl GroupDetails {
}
}
fn view_user_list(&self, g: &Group) -> Html {
fn view_user_list(&self, ctx: &Context<Self>, g: &Group) -> Html {
let link = ctx.link();
let make_user_row = |user: &User| {
let user_id = user.id.clone();
let display_name = user.display_name.clone();
html! {
<tr>
<td>
<Link route=AppRoute::UserDetails(user_id.clone())>
<Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
{user_id.clone()}
</Link>
</td>
<td>{display_name}</td>
<td>
<RemoveUserFromGroupComponent
username=user_id
group_id=g.id
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
on_error=self.common.callback(Msg::OnError)/>
username={user_id}
group_id={g.id}
on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
on_error={link.callback(Msg::OnError)}/>
</td>
</tr>
}
@ -133,7 +135,7 @@ impl GroupDetails {
<>
<h5 class="fw-bold">{"Members"}</h5>
<div class="table-responsive">
<table class="table table-striped">
<table class="table table-hover">
<thead>
<tr key="headerRow">
<th>{"User Id"}</th>
@ -145,7 +147,7 @@ impl GroupDetails {
{if g.users.is_empty() {
html! {
<tr key="EmptyRow">
<td>{"No members"}</td>
<td>{"There are no users in this group."}</td>
<td/>
</tr>
}
@ -159,7 +161,8 @@ impl GroupDetails {
}
}
fn view_add_user_button(&self, g: &Group) -> Html {
fn view_add_user_button(&self, ctx: &Context<Self>, g: &Group) -> Html {
let link = ctx.link();
let users: Vec<_> = g
.users
.iter()
@ -170,16 +173,16 @@ impl GroupDetails {
.collect();
html! {
<AddGroupMemberComponent
group_id=g.id
users=users
on_error=self.common.callback(Msg::OnError)
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
group_id={g.id}
users={users}
on_error={link.callback(Msg::OnError)}
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
}
}
}
impl CommonComponent<GroupDetails> for GroupDetails {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::GroupDetailsResponse(response) => match response {
Ok(group) => self.group = Some(group.group),
@ -215,24 +218,20 @@ impl Component for GroupDetails {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut table = Self {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
group: None,
};
table.get_group_details();
table.get_group_details(ctx);
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
match (&self.group, &self.common.error) {
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
@ -240,8 +239,8 @@ impl Component for GroupDetails {
html! {
<div>
{self.view_details(u)}
{self.view_user_list(u)}
{self.view_add_user_button(u)}
{self.view_user_list(ctx, u)}
{self.view_add_user_button(ctx, u)}
{self.view_messages(error)}
</div>
}

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

View File

@ -1,14 +1,15 @@
use crate::{
components::router::{AppRoute, NavButton},
components::router::{AppRoute, Link},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{anyhow, bail, Context, Result};
use anyhow::{anyhow, bail, Result};
use gloo_console::error;
use lldap_auth::*;
use validator_derive::Validate;
use yew::{prelude::*, services::ConsoleService};
use yew::prelude::*;
use yew_form::Form;
use yew_form_derive::Model;
@ -30,6 +31,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 +48,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 => {
@ -63,9 +70,9 @@ impl CommonComponent<LoginForm> for LoginForm {
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 +83,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 +94,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 +125,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>
@ -167,11 +167,11 @@ impl Component for LoginForm {
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
form={&self.form}
field_name="username"
placeholder="Username"
autocomplete="username"
oninput=self.common.callback(|_| Msg::Update) />
oninput={link.callback(|_| Msg::Update)} />
</div>
<div class="input-group">
<div class="input-group-prepend">
@ -183,7 +183,7 @@ impl Component for LoginForm {
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
form={&self.form}
field_name="password"
input_type="password"
placeholder="Password"
@ -193,16 +193,23 @@ impl Component for LoginForm {
<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-box-arrow-in-right me-2"/>
{"Login"}
</button>
<NavButton
classes="btn-link btn"
disabled=self.common.is_task_running()
route=AppRoute::StartResetPassword>
{"Forgot your password?"}
</NavButton>
{ if password_reset_enabled {
html! {
<Link
classes="btn-link btn"
disabled={self.common.is_task_running()}
to={AppRoute::StartResetPassword}>
{"Forgot your password?"}
</Link>
}
} else {
html!{}
}}
</div>
<div class="form-group">
{ if let Some(e) = &self.common.error {

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

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

View File

@ -1,5 +1,5 @@
use crate::{
components::router::{AppRoute, NavButton},
components::router::{AppRoute, Link},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@ -31,7 +31,11 @@ pub enum Msg {
}
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::Submit => {
@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
}
let FormModel { username } = self.form.model();
self.common.call_backend(
HostService::reset_password_step1,
&username,
ctx,
HostService::reset_password_step1(username),
Msg::PasswordResetResponse,
)?;
);
Ok(true)
}
Msg::PasswordResetResponse(response) => {
@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(_: &Context<Self>) -> Self {
ResetPasswordStep1Form {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
form: Form::<FormModel>::new(FormModel::default()),
just_succeeded: false,
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
self.just_succeeded = false;
CommonComponentParts::<Self>::update(self, msg)
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
type Field = yew_form::Field<FormModel>;
let link = &ctx.link();
html! {
<form
class="form center-block col-sm-4 col-offset-4">
@ -95,11 +96,11 @@ impl Component for ResetPasswordStep1Form {
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
form={&self.form}
field_name="username"
placeholder="Username"
placeholder="Username or email"
autocomplete="username"
oninput=self.common.callback(|_| Msg::Update) />
oninput={link.callback(|_| Msg::Update)} />
</div>
{ if self.just_succeeded {
html! {
@ -111,23 +112,24 @@ impl Component for ResetPasswordStep1Form {
<button
type="submit"
class="btn btn-primary"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
<i class="bi-check-circle me-2"/>
{"Reset password"}
</button>
<NavButton
<Link
classes="btn-link btn"
disabled=self.common.is_task_running()
route=AppRoute::Login>
disabled={self.common.is_task_running()}
to={AppRoute::Login}>
{"Back"}
</NavButton>
</Link>
</div>
}
}}
<div class="form-group">
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
<div class="alert alert-danger mb-2">
{e.to_string() }
</div>
}

View File

@ -1,11 +1,11 @@
use crate::{
components::router::AppRoute,
components::router::{AppRoute, Link},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Context, Result};
use anyhow::{bail, Result};
use lldap_auth::{
opaque::client::registration as opaque_registration,
password_reset::ServerPasswordResetResponse, registration,
@ -14,10 +14,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 +30,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 +46,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),
@ -73,10 +73,10 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
};
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 +94,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
registration_upload: registration_finish.message,
};
self.common.call_backend(
HostService::register_finish,
req,
ctx,
HostService::register_finish(req),
Msg::RegistrationFinishResponse,
)?;
);
Ok(false)
}
Msg::RegistrationFinishResponse(response) => {
self.common.cancel_task();
if response.is_ok() {
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
ctx.link().history().unwrap().push(AppRoute::Login);
}
response?;
Ok(true)
@ -121,35 +119,28 @@ impl Component for ResetPasswordStep2Form {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut component = ResetPasswordStep2Form {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<FormModel>::new(FormModel::default()),
opaque_data: None,
route_dispatcher: RouteAgentDispatcher::new(),
username: None,
};
let token = component.common.token.clone();
component
.common
.call_backend(
HostService::reset_password_step2,
&token,
Msg::ValidateTokenResponse,
)
.unwrap();
let token = ctx.props().token.clone();
component.common.call_backend(
ctx,
HostService::reset_password_step2(token),
Msg::ValidateTokenResponse,
);
component
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
match (&self.username, &self.common.error) {
(None, None) => {
return html! {
@ -158,9 +149,17 @@ impl Component for ResetPasswordStep2Form {
}
(None, Some(e)) => {
return html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
<>
<div class="alert alert-danger">
{e.to_string() }
</div>
<Link
classes="btn-link btn"
disabled={self.common.is_task_running()}
to={AppRoute::Login}>
{"Back"}
</Link>
</>
}
}
_ => (),
@ -178,14 +177,14 @@ impl Component for ResetPasswordStep2Form {
</label>
<div class="col-sm-10">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback">
{&self.form.field_message("password")}
</div>
@ -198,14 +197,14 @@ impl Component for ResetPasswordStep2Form {
</label>
<div class="col-sm-10">
<Field
form=&self.form
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) />
oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback">
{&self.form.field_message("confirm_password")}
</div>
@ -215,8 +214,8 @@ impl Component for ResetPasswordStep2Form {
<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})>
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
{"Submit"}
</button>
</div>

View File

@ -1,34 +1,30 @@
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("/")]
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>;

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

View File

@ -2,7 +2,7 @@ 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},
@ -47,7 +47,7 @@ pub struct Props {
}
impl CommonComponent<UserDetails> for UserDetails {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::UserDetailsResponse(response) => match response {
Ok(user) => self.user = Some(user.user),
@ -77,10 +77,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 +100,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 +131,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 +155,15 @@ impl UserDetails {
}
}
fn view_add_group_button(&self, u: &User) -> Html {
if self.common.is_admin {
fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
let link = &ctx.link();
if ctx.props().is_admin {
html! {
<AddUserToGroupComponent
username=u.id.clone()
groups=u.groups.clone()
on_error=self.common.callback(Msg::OnError)
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
username={u.id.clone()}
groups={u.groups.clone()}
on_error={link.callback(Msg::OnError)}
on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
}
} else {
html! {}
@ -172,24 +175,20 @@ impl Component for UserDetails {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let mut table = Self {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
user: None,
};
table.get_user_details();
table.get_user_details(ctx);
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
match (&self.user, &self.common.error) {
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
@ -197,17 +196,20 @@ impl Component for UserDetails {
html! {
<>
<h3>{u.id.to_string()}</h3>
<UserDetailsForm
user=u.clone() />
<div class="row justify-content-center">
<NavButton
route=AppRoute::ChangePassword(u.id.clone())
classes="btn btn-primary col-auto">
{"Change password"}
</NavButton>
<div class="d-flex flex-row-reverse">
<Link
to={AppRoute::ChangePassword{user_id: u.id.clone()}}
classes="btn btn-secondary">
<i class="bi-key me-2"></i>
{"Modify password"}
</Link>
</div>
{self.view_group_memberships(u)}
{self.view_add_group_button(u)}
<div>
<h5 class="row m-3 fw-bold">{"User details"}</h5>
</div>
<UserDetailsForm user={u.clone()} />
{self.view_group_memberships(ctx, u)}
{self.view_add_group_button(ctx, u)}
{self.view_messages(error)}
</>
}

View File

@ -5,15 +5,19 @@ use crate::{
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Error, Result};
use gloo_file::{
callbacks::{read_as_bytes, FileReader},
File,
};
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use wasm_bindgen::JsCast;
use yew::{prelude::*, services::ConsoleService};
use web_sys::{FileList, HtmlInputElement, InputEvent};
use yew::prelude::*;
use yew_form_derive::Model;
#[derive(PartialEq, Eq, Clone, Default)]
#[derive(Default)]
struct JsFile {
file: Option<web_sys::File>,
file: Option<File>,
contents: Option<Vec<u8>>,
}
@ -21,7 +25,7 @@ impl ToString for JsFile {
fn to_string(&self) -> String {
self.file
.as_ref()
.map(web_sys::File::name)
.map(File::name)
.unwrap_or_else(String::new)
}
}
@ -43,7 +47,6 @@ impl FromStr for JsFile {
pub struct UserModel {
#[validate(email)]
email: String,
#[validate(length(min = 1, message = "Display name is required"))]
display_name: String,
first_name: String,
last_name: String,
@ -65,17 +68,21 @@ pub struct UserDetailsForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<UserModel>,
avatar: JsFile,
reader: Option<FileReader>,
/// True if we just successfully updated the user, to display a success message.
just_updated: bool,
user: User,
}
pub enum Msg {
/// A form field changed.
Update,
/// A new file was selected.
FileSelected(File),
/// The "Submit" button was clicked.
SubmitClicked,
/// A picked file finished loading.
FileLoaded(yew::services::reader::FileData),
FileLoaded(String, Result<Vec<u8>>),
/// We got the response from the server about our update message.
UserUpdated(Result<update_user::ResponseData>),
}
@ -87,53 +94,47 @@ pub struct Props {
}
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg {
Msg::Update => {
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let input = document
.get_element_by_id("avatarInput")
.expect("Form field avatarInput should be present")
.dyn_into::<web_sys::HtmlInputElement>()
.expect("Should be an HtmlInputElement");
ConsoleService::log("Form update");
if let Some(files) = input.files() {
ConsoleService::log("Got file list");
if files.length() > 0 {
ConsoleService::log("Got a file");
let new_avatar = JsFile {
file: files.item(0),
contents: None,
};
if self.avatar.file.as_ref().map(|f| f.name())
!= new_avatar.file.as_ref().map(|f| f.name())
{
if let Some(ref file) = new_avatar.file {
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
}
self.avatar = new_avatar;
}
}
Msg::Update => Ok(true),
Msg::FileSelected(new_avatar) => {
if self.avatar.file.as_ref().map(|f| f.name()) != Some(new_avatar.name()) {
let file_name = new_avatar.name();
let link = ctx.link().clone();
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
link.send_message(Msg::FileLoaded(
file_name,
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
))
}));
self.avatar = JsFile {
file: Some(new_avatar),
contents: None,
};
}
Ok(true)
}
Msg::SubmitClicked => self.submit_user_update_form(),
Msg::SubmitClicked => self.submit_user_update_form(ctx),
Msg::UserUpdated(response) => self.user_update_finished(response),
Msg::FileLoaded(data) => {
self.common.cancel_task();
Msg::FileLoaded(file_name, data) => {
if let Some(file) = &self.avatar.file {
if file.name() == data.name {
if !is_valid_jpeg(data.content.as_slice()) {
if file.name() == file_name {
let data = data?;
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection.
self.avatar = JsFile::default();
bail!("Chosen image is not a valid JPEG");
} else {
self.avatar.contents = Some(data.content);
self.avatar.contents = Some(data);
return Ok(true);
}
}
}
self.reader = None;
Ok(false)
}
}
@ -148,35 +149,37 @@ impl Component for UserDetailsForm {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let model = UserModel {
email: props.user.email.clone(),
display_name: props.user.display_name.clone(),
first_name: props.user.first_name.clone(),
last_name: props.user.last_name.clone(),
email: ctx.props().user.email.clone(),
display_name: ctx.props().user.display_name.clone(),
first_name: ctx.props().user.first_name.clone(),
last_name: ctx.props().user.last_name.clone(),
};
Self {
common: CommonComponentParts::<Self>::create(props, link),
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::new(model),
avatar: JsFile::default(),
just_updated: false,
reader: None,
user: ctx.props().user.clone(),
}
}
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) -> Html {
fn view(&self, ctx: &Context<Self>) -> Html {
type Field = yew_form::Field<UserModel>;
let link = &ctx.link();
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
let avatar_string = avatar_base64.as_ref().unwrap_or(&self.common.user.avatar);
let avatar_string = avatar_base64
.as_deref()
.or(self.user.avatar.as_deref())
.unwrap_or("");
html! {
<div class="py-3">
<form class="form">
@ -186,40 +189,43 @@ impl Component for UserDetailsForm {
{"User ID: "}
</label>
<div class="col-8">
<span id="userId" class="form-constrol-static"><b>{&self.common.user.id}</b></span>
<span id="userId" class="form-control-static"><i>{&self.user.id}</i></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>
<label for="creationDate"
class="form-label col-4 col-form-label">
{"Creation date: "}
</label>
<div class="col-8">
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput=self.common.callback(|_| Msg::Update) />
<span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="uuid"
class="form-label col-4 col-form-label">
{"UUID: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="email"
class="form-label col-4 col-form-label">
{"Email*: "}
{"Email"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-8">
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
form={&self.form}
field_name="email"
autocomplete="email"
oninput=self.common.callback(|_| Msg::Update) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("email")}
</div>
@ -228,17 +234,17 @@ impl Component for UserDetailsForm {
<div class="form-group row mb-3">
<label for="display_name"
class="form-label col-4 col-form-label">
{"Display Name*: "}
{"Display Name: "}
</label>
<div class="col-8">
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
form={&self.form}
field_name="display_name"
autocomplete="name"
oninput=self.common.callback(|_| Msg::Update) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("display_name")}
</div>
@ -252,10 +258,10 @@ impl Component for UserDetailsForm {
<div class="col-8">
<Field
class="form-control"
form=&self.form
form={&self.form}
field_name="first_name"
autocomplete="given-name"
oninput=self.common.callback(|_| Msg::Update) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("first_name")}
</div>
@ -269,44 +275,56 @@ impl Component for UserDetailsForm {
<div class="col-8">
<Field
class="form-control"
form=&self.form
form={&self.form}
field_name="last_name"
autocomplete="family-name"
oninput=self.common.callback(|_| Msg::Update) />
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("last_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="creationDate"
class="form-label col-4 col-form-label">
{"Creation date: "}
<div class="form-group row align-items-center mb-3">
<label for="avatar"
class="form-label col-4 col-form-label">
{"Avatar: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-constrol-static">{&self.common.user.creation_date.date().naive_local()}</span>
<div class="row align-items-center">
<div class="col-8">
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Self::upload_files(input.files())
})} />
</div>
<div class="col-4">
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", avatar_string)}
style="max-height:128px;max-width:128px;height:auto;width:auto;"
alt="Avatar" />
</div>
</div>
</div>
</div>
<div class="form-group row 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">
<div class="form-group row justify-content-center mt-3">
<button
type="submit"
class="btn btn-primary col-auto col-form-label"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
{"Update"}
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
<i class="bi-save me-2"></i>
{"Save changes"}
</button>
</div>
</form>
{ if let Some(e) = &self.common.error {
{
if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
@ -314,8 +332,8 @@ 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>
}
@ -323,12 +341,10 @@ impl Component for UserDetailsForm {
}
impl UserDetailsForm {
fn submit_user_update_form(&mut self) -> Result<bool> {
ConsoleService::log("Submit");
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
if !self.form.validate() {
bail!("Invalid inputs");
}
ConsoleService::log("Valid inputs");
if let JsFile {
file: Some(_),
contents: None,
@ -336,10 +352,9 @@ impl UserDetailsForm {
{
bail!("Image file hasn't finished loading, try again");
}
ConsoleService::log("File is correctly loaded");
let base_user = &self.common.user;
let base_user = &self.user;
let mut user_input = update_user::UpdateUserInput {
id: self.common.user.id.clone(),
id: self.user.id.clone(),
email: None,
displayName: None,
firstName: None,
@ -364,12 +379,11 @@ impl UserDetailsForm {
user_input.avatar = maybe_to_base64(&self.avatar)?;
// Nothing changed.
if user_input == default_user_input {
ConsoleService::log("No changes");
return Ok(false);
}
let req = update_user::Variables { user: user_input };
ConsoleService::log("Querying");
self.common.call_graphql::<UpdateUser, _>(
ctx,
req,
Msg::UserUpdated,
"Error trying to update user",
@ -378,23 +392,30 @@ impl UserDetailsForm {
}
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
self.common.cancel_task();
match r {
Err(e) => return Err(e),
Ok(_) => {
let model = self.form.model();
self.common.user.email = model.email;
self.common.user.display_name = model.display_name;
self.common.user.first_name = model.first_name;
self.common.user.last_name = model.last_name;
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
self.common.user.avatar = avatar;
}
self.just_updated = true;
}
};
r?;
let model = self.form.model();
self.user.email = model.email;
self.user.display_name = model.display_name;
self.user.first_name = model.first_name;
self.user.last_name = model.last_name;
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
self.user.avatar = Some(avatar);
}
self.just_updated = true;
Ok(true)
}
fn upload_files(files: Option<FileList>) -> Msg {
if let Some(files) = files {
if files.length() > 0 {
Msg::FileSelected(File::from(files.item(0).unwrap()))
} else {
Msg::Update
}
} else {
Msg::Update
}
}
}
fn is_valid_jpeg(bytes: &[u8]) -> bool {

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

View File

@ -1,136 +1,84 @@
use super::cookies::set_cookie;
use anyhow::{anyhow, Context, Result};
use gloo_net::http::{Method, Request};
use graphql_client::GraphQLQuery;
use lldap_auth::{login, registration, JWTClaims};
use yew::callback::Callback;
use yew::format::Json;
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
use serde::{de::DeserializeOwned, Serialize};
use web_sys::RequestCredentials;
#[derive(Default)]
pub struct HostService {}
fn get_default_options() -> FetchOptions {
FetchOptions {
credentials: Some(Credentials::SameOrigin),
..FetchOptions::default()
}
}
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
use jwt::*;
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
Ok(token.claims().clone())
}
fn create_handler<Resp, CallbackResult, F>(
callback: Callback<Result<CallbackResult>>,
handler: F,
) -> Callback<Response<Result<Resp>>>
where
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
CallbackResult: 'static,
{
Callback::once(move |response: Response<Result<Resp>>| {
let (meta, maybe_data) = response.into_parts();
let message = maybe_data
.context("Could not reach server")
.and_then(|data| handler(meta.status, data));
callback.emit(message)
})
}
const NO_BODY: Option<()> = None;
struct RequestBody<T>(T);
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))
async fn call_server(
url: &str,
body: Option<impl Serialize>,
error_message: &'static str,
) -> Result<String> {
let mut request = Request::new(url)
.header("Content-Type", "application/json")
.credentials(RequestCredentials::SameOrigin);
if let Some(b) = body {
request = request
.body(serde_json::to_string(&b)?)
.method(Method::POST);
}
let 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: Option<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: Option<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 +95,103 @@ 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(
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
"/api/graphql",
&request_body,
callback,
Some(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,
Some(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(
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
"/auth/opaque/login/finish",
&request,
callback,
Some(request),
"Could not finish authentication",
parse_token,
)
.await
.and_then(set_cookies_from_jwt)
}
pub fn register_start(
pub async fn register_start(
request: registration::ClientRegistrationStartRequest,
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>,
) -> Result<FetchTask> {
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
call_server_json_with_error_message(
"/auth/opaque/register/start",
&request,
callback,
Some(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,
Some(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(
pub async fn refresh() -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
"/auth/refresh",
yew::format::Nothing,
callback,
NO_BODY,
"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> {
call_server_empty_response_with_error_message(
"/auth/logout",
yew::format::Nothing,
callback,
"Could not logout",
)
pub async fn logout() -> Result<()> {
call_server_empty_response_with_error_message("/auth/logout", NO_BODY, "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/{}", url_escape::encode_query(&username)),
NO_BODY,
"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,
NO_BODY,
"Could not validate token",
)
.await
}
pub async fn probe_password_reset() -> Result<bool> {
Ok(
gloo_net::http::Request::get("/auth/reset/step1/lldap_unlikely_very_long_user_name")
.header("Content-Type", "application/json")
.send()
.await?
.status()
!= http::StatusCode::NOT_FOUND,
)
}
}

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,
);
}
}

View File

@ -53,7 +53,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(())
}

View File

@ -1,16 +1,16 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "bootstrap")]
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen]
#[wasm_bindgen(js_namespace = bootstrap)]
pub type Modal;
#[wasm_bindgen(constructor)]
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
pub fn new(e: web_sys::Element) -> Modal;
#[wasm_bindgen(method)]
#[wasm_bindgen(method, js_namespace = bootstrap)]
pub fn show(this: &Modal);
#[wasm_bindgen(method)]
#[wasm_bindgen(method, js_namespace = bootstrap)]
pub fn hide(this: &Modal);
}

View File

@ -1,6 +1,8 @@
#![recursion_limit = "256"]
#![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)]
#![allow(clippy::uninlined_format_args)]
#![allow(clippy::let_unit_value)]
pub mod components;
pub mod infra;
@ -8,7 +10,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
#[wasm_bindgen]
pub fn run_app() -> Result<(), JsValue> {
yew::start_app::<components::app::App>();
yew::start_app::<components::app::AppContainer>();
Ok(())
}

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
app/static/main.js Normal file
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()

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
}

View File

@ -1,6 +1,6 @@
[package]
name = "lldap_auth"
version = "0.3.0-alpha.1"
version = "0.3.0"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"

View File

@ -26,9 +26,9 @@ Frontend:
Data storage:
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
* Currently only SQLite is supported (see
https://github.com/launchbadge/sqlx/issues/1225 for what blocks us from
supporting more SQL backends).
* The main SQL DBs are supported: SQLite by default, MySQL, MariaDB, PostgreSQL
(see [DB Migration](/database_migration.md) for how to migrate off of
SQLite).
### Code organization

BIN
docs/cookie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

109
docs/database_migration.md Normal file
View File

@ -0,0 +1,109 @@
# Migration
Existing servers can migrate from one database backend to another. This page includes guidance for migrating from SQLite - similar concepts apply when migrating from databases of other types.
NOTE: [pgloader](https://github.com/dimitri/pgloader) is a tool that can easily migrate to PostgreSQL from other databases. Consider it if your target database is PostgreSQL
The process is as follows:
1. Create empty schema on target database
2. Stop/pause LLDAP and dump existing values
3. Sanitize for target DB (not always required)
4. Insert data into target
5. Change LLDAP config to new target and restart
The steps below assume you already have PostgreSQL or MySQL set up with an empty database for LLDAP to use.
## Create schema on target
LLDAP has a command that will connect to a target database and initialize the
schema. If running with docker, run the following command to use your active
instance (this has the benefit of ensuring your container has access):
```
docker exec -it <LLDAP container name> /app/lldap create_schema -d <Target database url>
```
If it succeeds, you can proceed to the next step.
## Create a dump of existing data
We want to dump (almost) all existing values to some file - the exception being the `metadata` table (and sometimes
the `sqlite_sequence` table, when it exists). Be sure to stop/pause LLDAP during this step, as some
databases (SQLite in this example) will give an error if LLDAP is in the middle of a write. The dump should consist just INSERT
statements. There are various ways to do this, but a simple enough way is filtering a
whole database dump. This repo contains [a script](/scripts/sqlite_dump_commands.sh) to generate SQLite commands for creating an appropriate dump:
```
./sqlite_dump_commands.sh | sqlite3 /path/to/lldap/config/users.db > /path/to/dump.sql
```
## Sanitize data
Some databases might use different formats for some data - for example, PostgreSQL uses
a different syntax for hex strings than SQLite. We also want to make sure inserts are done in
a transaction in case one of the statements fail.
### To PostgreSQL
PostgreSQL uses a different hex string format. The command below should switch SQLite
format to PostgreSQL format, and wrap it all in a transaction:
```
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" \
-e '1s/^/BEGIN;\n/' \
-e '$aCOMMIT;' /path/to/dump.sql
```
### To MySQL
MySQL mostly cooperates, but it gets some errors if you don't escape the `groups` table. It also uses
backticks to escape table name instead of quotes. Run the
following command to wrap all table names in backticks for good measure, and wrap the inserts in
a transaction:
```
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
-e '1s/^/START TRANSACTION;\n/' \
-e '$aCOMMIT;' \
-e '1 i\SET FOREIGN_KEY_CHECKS = 0;' /path/to/dump.sql
```
### To MariaDB
While MariaDB is supposed to be identical to MySQL, it doesn't support timezone offsets on DATETIME
strings. Use the following command to remove those and perform the additional MySQL sanitization:
```
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \
-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
-e '1s/^/START TRANSACTION;\n/' \
-e '$aCOMMIT;' \
-e '1 i\SET FOREIGN_KEY_CHECKS = 0;' /path/to/dump.sql
```
## Insert data
Insert the data generated from the previous step into the target database. If you encounter errors,
you may need to manually tweak your dump, or make changed in LLDAP and recreate the dump.
### PostgreSQL
`psql -d <database> -U <username> -W < /path/to/dump.sql`
or
`psql -d <database> -U <username> -W -f /path/to/dump.sql`
### MySQL
`mysql -u <username> -p <database> < /path/to/dump.sql`
## Switch to new database
Modify your `database_url` in `lldap_config.toml` (or `LLDAP_DATABASE_URL` in the env)
to point to your new database (the same value used when generating schema). Restart
LLDAP and check the logs to ensure there were no errors.
#### More details/examples can be seen in the CI process [here](https://raw.githubusercontent.com/nitnelave/lldap/main/.github/workflows/docker-build-static.yml), look for the job `lldap-database-migration-test`

90
docs/scripting.md Normal file
View File

@ -0,0 +1,90 @@
# Scripting
Programmatically accessing LLDAP can be done either through the LDAP protocol,
or via the GraphQL API.
## LDAP
Most _read-only_ queries about users and groups are supported. Anything not
supported would be considered a missing feature or a bug.
Most _modification_ queries are not supported, except for creating users and
changing the password (through the extended password operation). Those could be
added in the future, on a case-by-case basis.
Most _meta_-queries about the LDAP server itself are not supported and are out
of scope. That includes anything that touches the schema, for instance. LLDAP
still supports basic RootDSE queries.
Anonymous bind is not supported.
## GraphQL
The best way to interact with LLDAP programmatically is via the GraphQL
interface. You can use any language that has a GraphQL library (most of them
do), and use the [GraphQL Schema](../schema.graphql) to guide your queries.
### Getting a token
You'll need a JWT (authentication token) to issue GraphQL queries. Your view of
the system will be limited by the rights of your user. In particular, regular
users can only see themselves and the groups they belong to (but not other
members of these groups, for instance).
#### Manually
Log in to the web front-end of LLDAP. Then open the developer tools (F12), find
the "Storage > Cookies", and you'll find the "token" cookie with your JWT.
![Cookies menu with a JWT](cookie.png)
#### Automatically
The easiest way is to send a json POST request to `/auth/simple/login` with
`{"username": "john", "password": "1234"}` in the body.
Then you'll receive a JSON response with:
```
{
"token": "eYbat...",
"refreshToken": "3bCka...",
}
```
### Using the token
You can use the token directly, either as a cookie, or as a bearer auth token
(add an "Authorization" header with contents `"Bearer <token>"`).
The JWT is valid for 1 day (unless you log out explicitly).
You can use the refresh token to query `/auth/refresh` and get another JWT. The
refresh token is valid for 30 days.
### Testing your GraphQL queries
You can go to `/api/graphql/playground` to test your queries and explore the
data in the playground. You'll need to provide the JWT in the headers:
```
{ "Authorization": "Bearer abcdef123..." }
```
Then you can enter your query, for instance:
```graphql
{
user(userId:"admin") {
displayName
}
groups {
id
displayName
users {
id
email
}
}
}
```
The schema is on the right, along with some basic docs.

View File

@ -30,11 +30,11 @@ authentication_backend:
additional_users_dn: ou=people
# To allow sign in both with username and email, one can use a filter like
# (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))
users_filter: (&({username_attribute}={input})(objectClass=person))
users_filter: "(&({username_attribute}={input})(objectClass=person))"
# Set this to ou=groups, because all groups are stored in this ou
additional_groups_dn: ou=groups
# Only this filter is supported right now
groups_filter: (member={dn})
groups_filter: "(member={dn})"
# The attribute holding the name of the group.
group_name_attribute: cn
# Email attribute

View File

@ -0,0 +1,105 @@
# Name
```
lldap
```
# Slug
```
lldap
```
- [x] Enabled
- [x] Sync Users
- [x] User password writeback
- [x] Sync groups
# Connection settings
## Server URI
```
ldap://lldap:3890
```
- [ ] Enable StartTLS
## TLS Verification Certificate
```
---------
```
## Bind CN
```
uid=admin,ou=people,dc=example,dc=com
```
## Bind Password
```
ADMIN_PASSWORD
```
## Base DN
```
dc=example,dc=com
```
# LDAP Attribute mapping
## User Property Mappings
- [x] authentik default LDAP Mapping: mail
- [x] authentik default LDAP Mapping: Name
- [x] authentik default Active Directory Mapping: givenName
- [ ] authentik default Active Directory Mapping: sAMAccountName
- [x] authentik default Active Directory Mapping: sn
- [ ] authentik default Active Directory Mapping: userPrincipalName
- [x] authentik default OpenLDAP Mapping: cn
- [x] authentik default OpenLDAP Mapping: uid
## Group Property Mappings
- [ ] authentik default LDAP Mapping: mail
- [ ] authentik default LDAP Mapping: Name
- [ ] authentik default Active Directory Mapping: givenName
- [ ] authentik default Active Directory Mapping: sAMAccountName
- [ ] authentik default Active Directory Mapping: sn
- [ ] authentik default Active Directory Mapping: userPrincipalName
- [x] authentik default OpenLDAP Mapping: cn
- [ ] authentik default OpenLDAP Mapping: uid
# Additional settings
## Group
```
---------
```
## User path
```
LDAP/users
```
## Addition User DN
```
ou=people
```
## Addition Group DN
```
ou=groups
```
## User object filter
```
(objectClass=person)
```
## Group object filter
```
(objectClass=groupOfUniqueNames)
```
## Group membership field
```
member
```
## Object uniqueness field
```
uid
```

View File

@ -0,0 +1,57 @@
# Configuration for Dell iDRAC
## iDRAC 9
iDRAC 9 can only be connected to LDAPS, so make sure you have that enabled.
The settings then are as follows:
### Use Distinguished Name to Search Group Membership
```
Enabled
```
### LDAP Server Address
```
Your server address eg. localhost
```
### LDAP Server Port
```
Your LDAPS port, eg. 6360 or 636
```
### Bind DN
```
uid=admin,ou=people,dc=example,dc=com
```
### Bind Password
```
Enabled
```
### Bind Password
```
Your admin user password
```
### Attribute of User Login
```
uid
```
### Attribute of Group Membership
```
member
```
### Search Filter
```
(&(objectClass=person)(memberof=cn=idrac_users,ou=groups,dc=example,dc=com))
```
For the Group Role Mappings, you define groups by their full `Group DN`, eg.
```
cn=idrac_users,ou=groups,dc=example,dc=com
```

View File

@ -0,0 +1,32 @@
# lldap configuration:
# LLDAP_LDAP_BASE_DN: dc=example,dc=com
# ##############################
# rest of the Dex options
# ##############################
connectors:
- type: ldap
id: ldap
name: LDAP
config:
host: lldap-host # make sure it does not start with `ldap://`
port: 3890 # or 6360 if you have ldaps enabled
insecureNoSSL: true # or false if you have ldaps enabled
insecureSkipVerify: true # or false if you have ldaps enabled
bindDN: uid=admin,ou=people,dc=example,dc=com # replace admin with your admin user
bindPW: very-secure-password # replace with your admin password
userSearch:
baseDN: ou=people,dc=example,dc=com
username: uid
idAttr: uid
emailAttr: mail
nameAttr: displayName
preferredUsernameAttr: uid
groupSearch:
baseDN: ou=groups,dc=example,dc=com
filter: "(objectClass=groupOfUniqueNames)"
userMatchers:
- userAttr: DN
groupAttr: member
nameAttr: cn

View File

@ -10,7 +10,7 @@ $conf['plugin']['authldap']['server'] = 'ldap://lldap_server:3890'; #IP of
$conf['plugin']['authldap']['usertree'] = 'ou=people,dc=example,dc=com';
$conf['plugin']['authldap']['grouptree'] = 'ou=groups, dc=example, dc=com';
$conf['plugin']['authldap']['userfilter'] = '(&(uid=%{user})(objectClass=person))';
$conf['plugin']['authldap']['groupfilter'] = '(&(objectClass=group)(memberUID=member))';
$conf['plugin']['authldap']['groupfilter'] = '(objectClass=group)';
$conf['plugin']['authldap']['attributes'] = array('cn', 'displayname', 'mail', 'givenname', 'objectclass', 'sn', 'uid', 'memberof');
$conf['plugin']['authldap']['version'] = 3;
$conf['plugin']['authldap']['binddn'] = 'cn=admin,ou=people,dc=example,dc=com';

View File

@ -1,4 +1,4 @@
# Configuration for Gitea
# Configuration for Gitea (& Forgejo)
In Gitea, go to `Site Administration > Authentication Sources` and click `Add Authentication Source`
Select `LDAP (via BindDN)`
@ -14,9 +14,36 @@ To log in they can either use their email address or user name. If you only want
For more info on the user filter, see: https://docs.gitea.io/en-us/authentication/#ldap-via-binddn
* Admin Filter: Use `(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)` if you want lldap admins to become Gitea admins. Leave empty otherwise.
* Username Attribute: `uid`
* First Name Attribute: `givenName`
* Surname Attribute: `sn`
* Email Attribute: `mail`
* Avatar Attribute: `jpegPhoto`
* Check `Enable User Synchronization`
Replace every instance of `dc=example,dc=com` with your configured domain.
After applying the above settings, users should be able to log in with either their user name or email address.
After applying the above settings, users should be able to log in with either their user name or email address.
## Syncronizing LDAP groups with existing teams in organisations
Groups in LLDAP can be syncronized with teams in organisations. Organisations and teams must be created manually in Gitea.
It is possible to syncronize one LDAP group with multiple teams in a Gitea organization.
Check `Enable LDAP Groups`
* Group Search Base DN: `ou=groups,dc=example,dc=com`
* Group Attribute Containing List Of Users: `member`
* User Attribute Listed In Group: `dn`
* Map LDAP groups to Organization teams: `{"cn=Groupname1,ou=groups,dc=example,dc=com":{"Organization1": ["Teamname"]},"cn=Groupname2,ou=groups,dc=example,dc=com": {"Organization2": ["Teamname1", "Teamname2"]}}`
Check `Remove Users from syncronised teams...`
The `Map LDAP groups to Organization teams` config is JSON formatted and can be extended to as many groups as needed.
Replace every instance of `dc=example,dc=com` with your configured domain.
# Configuration for Gitea in `simple auth` mode
* The configuration method is the same as `BindDN` mode.
* `BindDN` and `password` are not required
* Gitea will not be able to pre-sync users, user account will be created at login time.

View File

@ -0,0 +1,23 @@
# Home Assistant Configuration
Home Assistant configures ldap auth via the [Command Line Auth Provider](https://www.home-assistant.io/docs/authentication/providers/#command-line). The wiki mentions a script that can be used for LDAP authentication, but it doesn't work in the container version (it is lacking both `ldapsearch` and `curl` ldap protocol support). Thankfully LLDAP has a graphql API to save the day!
## Graphql-based Auth Script
The [auth script](lldap-ha-auth.sh) attempts to authenticate a user against an LLDAP server, using credentials provided via `username` and `password` environment variables. The first argument must be the URL of your LLDAP server, accessible from Home Assistant. You can provide an additional optional argument to confine allowed logins to a single group. The script will output the user's display name as the `name` variable, if not empty.
1. Copy the [auth script](lldap-ha-auth.sh) to your home assistant instance. In this example, we use `/config/lldap-auth.sh`.
2. Add the following to your configuration.yaml in Home assistant:
```yaml
homeassistant:
auth_providers:
# Ensure you have the homeassistant provider enabled if you want to continue using your existing accounts
- type: homeassistant
- type: command_line
command: /config/lldap-auth.sh
# Only allow users in the 'homeassistant_user' group to login.
# Change to ["https://lldap.example.com"] to allow all users
args: ["https://lldap.example.com", "homeassistant_user"]
meta: true
```
3. Reload your config or restart Home Assistant

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View File

@ -35,6 +35,12 @@ Otherwise, just use:
```
(uid=*)
```
### Admin Base DN
The DN of your admin group. If you have `media_admin` as your group you would use:
```
cn=media_admin,ou=groups,dc=example,dc=com
```
### Admin Filter

View File

@ -0,0 +1,33 @@
# configuration for Kanboard
add these to the Kanboard `config.php`
Make sure you adapt the constant `LDAP_SERVER` to the correct LDAP endpoint.
You also might have to change `dc=example,dc=com` to whatever your LLDAP is handling, and maybe change the `kanboard_users` group name used in `LDAP_USER_FILTER` to identify users of a valid group of yours.
```
define('LDAP_AUTH', true);
define('LDAP_SERVER', 'ldap://lldap-server.com:3890');
define('LDAP_SSL_VERIFY', true);
define('LDAP_START_TLS', false);
define('LDAP_USERNAME_CASE_SENSITIVE', false);
define('LDAP_USER_CREATION', true);
define('LDAP_BIND_TYPE', 'user');
define('LDAP_USERNAME', 'uid=%s,ou=people,dc=example,dc=com');
define('LDAP_PASSWORD', null);
define('LDAP_USER_BASE_DN', 'ou=people,dc=example,dc=com');
define('LDAP_USER_FILTER', '(&(uid=%s)(memberof=cn=kanboard_users,ou=groups,dc=example,dc=com))');
define('LDAP_USERNAME_CASE_SENSITIVE', false);
define('LDAP_USER_ATTRIBUTE_USERNAME', 'uid');
define('LDAP_USER_ATTRIBUTE_FULLNAME', 'cn');
define('LDAP_USER_ATTRIBUTE_EMAIL', 'mail');
define('LDAP_USER_ATTRIBUTE_GROUPS', 'memberof');
define('LDAP_USER_ATTRIBUTE_PHOTO', 'jpegPhoto');
define('LDAP_USER_ATTRIBUTE_LANGUAGE', '');
```

View File

@ -0,0 +1,70 @@
#!/bin/bash
# Usernames should be validated using a regular expression to be of
# a known format. Special characters will be escaped anyway, but it is
# generally not recommended to allow more than necessary.
# This pattern is set by default. In your config file, you can either
# overwrite it with a different one or use "unset USERNAME_PATTERN" to
# disable validation completely.
USERNAME_PATTERN='^[a-z|A-Z|0-9|_|-|.]+$'
# When the timeout (in seconds) is exceeded (e.g. due to slow networking),
# authentication fails.
TIMEOUT=3
# Log messages to stderr.
log() {
echo "$1" >&2
}
# Get server address
if [ -z "$1" ]; then
log "Usage: lldap-auth.sh <LLDAP server address> <Optional group to filter>"
exit 2
fi
SERVER_URL="${1%/}"
# Check username and password are present and not malformed.
if [ -z "$username" ] || [ -z "$password" ]; then
log "Need username and password environment variables."
exit 2
elif [ ! -z "$USERNAME_PATTERN" ]; then
username_match=$(echo "$username" | sed -r "s/$USERNAME_PATTERN/x/")
if [ "$username_match" != "x" ]; then
log "Username '$username' has an invalid format."
exit 2
fi
fi
RESPONSE=$(curl -f -s -X POST -m "$TIMEOUT" -H "Content-type: application/json" -d '{"username":"'"$username"'","password":"'"$password"'"}' "$SERVER_URL/auth/simple/login")
if [[ $? -ne 0 ]]; then
log "Auth failed"
exit 1
fi
TOKEN=$(jq -e -r .token <<< $RESPONSE)
if [[ $? -ne 0 ]]; then
log "Failed to parse token"
exit 1
fi
RESPONSE=$(curl -f -s -m "$TIMEOUT" -H "Content-type: application/json" -H "Authorization: Bearer ${TOKEN}" -d '{"variables":{"id":"'"$username"'"},"query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' "$SERVER_URL/api/graphql")
if [[ $? -ne 0 ]]; then
log "Failed to get user"
exit 1
fi
USER_JSON=$(jq -e .data.user <<< $RESPONSE)
if [[ $? -ne 0 ]]; then
log "Failed to parse user json"
exit 1
fi
if [[ ! -z "$2" ]] && ! jq -e '.groups|map(.displayName)|index("'"$2"'")' <<< $USER_JSON > /dev/null 2>&1; then
log "User is not in group '$2'"
exit 1
fi
DISPLAY_NAME=$(jq -r .displayName <<< $USER_JSON)
[[ ! -z "$DISPLAY_NAME" ]] && echo "name = $DISPLAY_NAME"

View File

@ -5,8 +5,24 @@
This example is using following users & groups in lldap :
* A technical user (ex: `ro_admin`), member of `lldap_strict_readonly` or `lldap_password_manager`
* Several accounts, members of `users` group will be authorized to log in Nextcloud (eg neither `admin` nor `ro_admin`)
* Some "application" groups, let's say `friends` and `family`: users in Nextcloud will be able to share files and view people in dynamic lists only to members of their own group(s)
* A catch-all group called `nextcloud_users`.
* Members of `nextcloud_users` group will be authorized to log in Nextcloud.
* Some "application" groups, let's say `friends` and `family`: users in Nextcloud will be able to share files and view people in dynamic lists only to members of their own group(s).
* Users in `family` and `friends` should also be users in `nextcloud_users` group!
If you plan on following this tutorial line-by-line, you will now have the following:
* 6 groups:
1. `nextcloud_users`
2. `family`
3. `friends`
4. `lldap_strict_readonly`
5. `lldap_password_manager`
6. `ldap_admin`
* 1 admin user in any of the following groups:
1. `lldap_password_manager`
2. `lldap_strict_readonly`
* (Atleast) 1 user in the `nextcloud_users` group
* (Optional) Any number of users in the `friends` or `family` group.
## Nextcloud config : the cli way
@ -16,6 +32,7 @@ TL;DR let's script it. The "user_ldap" application is shipped with default Nextc
occ app:install user_ldap
occ app:enable user_ldap
occ ldap:create-empty-config
# EDIT: domain
occ ldap:set-config s01 ldapHost "ldap://lldap.example.net."
occ ldap:set-config s01 ldapPort 3890
@ -29,8 +46,8 @@ occ ldap:set-config s01 ldapBaseUsers "dc=example,dc=com"
occ ldap:set-config s01 ldapBaseGroups "dc=example,dc=com"
occ ldap:set-config s01 ldapConfigurationActive 1
occ ldap:set-config s01 ldapLoginFilter "(&(objectclass=person)(uid=%uid))"
# EDIT: users group, contains the users who can login to Nextcloud
occ ldap:set-config s01 ldapUserFilter "(&(objectclass=person)(memberOf=cn=users,ou=groups,dc=example,dc=com))"
# EDIT: nextcloud_users group, contains the users who can login to Nextcloud
occ ldap:set-config s01 ldapUserFilter "(&(objectclass=person)(memberOf=cn=nextcloud_users,ou=groups,dc=example,dc=com))"
occ ldap:set-config s01 ldapUserFilterMode 0
occ ldap:set-config s01 ldapUserFilterObjectclass person
occ ldap:set-config s01 turnOnPasswordChange 0
@ -45,6 +62,7 @@ occ ldap:set-config s01 ldapGroupFilterMode 0
occ ldap:set-config s01 ldapGroupDisplayName cn
occ ldap:set-config s01 ldapGroupFilterObjectclass groupOfUniqueNames
occ ldap:set-config s01 ldapGroupMemberAssocAttr uniqueMember
occ ldap:set-config s01 ldapEmailAttribute "mail"
occ ldap:set-config s01 ldapLoginFilterEmail 0
occ ldap:set-config s01 ldapLoginFilterUsername 1
occ ldap:set-config s01 ldapMatchingRuleInChainState unknown
@ -57,7 +75,7 @@ occ ldap:set-config s01 ldapUserFilterMode 1
occ ldap:set-config s01 ldapUuidGroupAttribute auto
occ ldap:set-config s01 ldapUuidUserAttribute auto
```
With small amount of luck, you should be able to log in your nextcloud instance with LLDAP accounts in the `users` group.
With a bit of of luck, you should be able to log in your nextcloud instance with LLDAP accounts in the `nextcloud_users` group.
## Nextcloud config : the GUI way
@ -74,9 +92,9 @@ Fill the LLDAP domain and port, DN + password of your technical account and base
### Users tab
Select `person` as object class and then choose `Edit LDAP Query` : the `only from these groups` option is not functional.
We want only users from the `users` group to be allowed to log in Nextcloud :
We want only users from the `nextcloud_users` group to be allowed to log in Nextcloud :
```
(&(objectclass=person)(memberOf=cn=users,ou=groups,dc=example,dc=com))
(&(objectclass=person)(memberOf=cn=nextcloud_users,ou=groups,dc=example,dc=com))
```
![login configuration page](images/nextcloud_loginfilter.png)
@ -99,13 +117,10 @@ You can use the menus for this part : select `groupOfUniqueNames` in the first m
![groups configuration page](images/nextcloud_groups.png)
The resulting LDAP filter could be simplified removing the first 'OR' condition (I think).
## Sharing restrictions
Go to Settings > Administration > Sharing and check following boxes :
* "Allow username autocompletion to users within the same groups"
* "Restrict users to only share with users in their groups"
![sharing options](images/nextcloud_sharing_options.png)

View File

@ -0,0 +1,90 @@
## Assumptions
If you're here, there are some assumptions being made about access and capabilities you have on your system:
1. You have Authelia up and running, understand its functionality, and have read through the documentation.
2. You have [LLDAP](https://github.com/nitnelave/lldap) up and running.
3. You have Nextcloud and LLDAP communicating and without any config errors. See the [example config for Nextcloud](nextcloud.md)
## Authelia
Set up Authelia according to its [documentation](https://www.authelia.com/overview/prologue/introduction/), including the [OpenID Connect](https://www.authelia.com/configuration/identity-providers/open-id-connect/) and [Nextcloud instructions](https://www.authelia.com/integration/openid-connect/nextcloud/).
## LLDAP
With LLDAP up and running, add a group and note the name you use. For this tutorial, we're using the group `nextcloud_users`. Create a new user and add it to the `nextcloud_users` group.
#### Optional:
Once setup, add an admin or config user and add to the `lldap_strict_readonly` or `lldap_password_manager` group. This will be the config account used for Nextcloud to read your groups and users from the server.
## Nextcloud
**_When we get to the OpenID section, we will be using the same defaults as Authelia's documentation. As a reminder, they are:_**
* **Application Root URL:** https://nextcloud.example.com
* **Authelia Root URL:** https://auth.example.com
* [**Client ID:**](https://www.authelia.com/configuration/identity-providers/open-id-connect/#id) nextcloud
* [**Client Secret:**](https://www.authelia.com/configuration/identity-providers/open-id-connect/#secret) nextcloud_client_secret
Login to your Nextcloud instance as an admin. By now, you should have correctly setup Nextcloud and LLDAP to be communicating and working as expected. [See assumptions, above](#assumptions)
Next, navigate to the `Apps` section.
![nextcloud_apps.png](images/nextcloud_apps.png)
Search for the Nextcloud app [Social Login](https://apps.nextcloud.com/apps/sociallogin). Enable the app.
Once enabled, navigate to Settings > Administration > Social Login.
You'll see many different options for various auth methods, including major 3rd party integrations. For the top section, check off these three options:
* Allow Users to Connect Social Logins with their Account
* Prevent creating an account if the email address exists in another account
* Update user profile every login
![nextcloud_sociallogin_checkboxes](images/nextcloud_sociallogin_checkboxes.png)
_You can test out the other options such as preventing users without a group, but I haven't tested all the options. These are just the ones I know that worked so far._
Scroll down and select **Custom OpenID Connect**. Fill out the following options:
_The first two can be any string you'd like to identify the connection with. The Title is the string that will show up on the button at the login screen and the Internal Name will be used in the Redirect uri. [See point 3 in the section below](#some-notes)._
| Field | Value |
|--|--|
| Internal Name | Authelia |
|Title | Authelia OpenID |
|Authorize URL | https://auth.example.com/api/oidc/authorization |
|Token URL | https://auth.example.com/api/oidc/token |
|Display Name Claim (Optional) | display_name |
|User info URL (Optional) | |
|Logout URL (Optional) | |
|Client ID: | nextcloud |
|Client Secret: | nextcloud_client_secret |
|Scope: | openid profile email groups |
|Groups Claim (Optional) | |
|Button Style | None |
|Default Group | nextcloud_users |
#### Some Notes
* The *scope* should be the same as the scope that was setup in [Authelia's OpenID Integration](https://www.authelia.com/integration/openid-connect/nextcloud/#authelia). Here's an example from Authelia:
![Authelia OpenID Config](images/authelia_openid_config.png)
* *_Do not_* use commas in the Nextcloud Social Login app scope! This caused many issues for me.
* Be sure you update your Authelia `configuration.yml`. Specifically, the line: `redirect_uris`. The new URL should be
`https://auth.example.com/index.php/apps/sociallogin/custom_oidc/Authelia`.
* The final field in the URL (Authelia) needs to be the same value you used in the Social Login "Internal Name" field.
* If you've setup LLDAP correctly in nextcloud, the last dropdown for _Default Group_ should show you the `nextcloud_users` group you setup in LLDAP.
Once you've filled out the fields correctly, scroll to the bottom and hit save and confirm that you don't recieve any errors from Nextcloud.
#### Config.php
Lastly, we need to add the following line to your `config.php` file on your Nextcloud Server.
* `'social_login_auto_redirect' => false,`
If this is set to *false* your login screen will show the standard User/Email and Password input fields with an additional button underneath that should say: `Login with Authelia OpenID` (the name is coming from the Title field in the Social Login options we setup earlier).
If this is set to *true* then the user flow will _skip_ the login page and automatically bring you to the Authelia Consent Page at `https://auth.example.com/consent?consent_id=alphanuber-uuid-string`
### Conclusion
And that's it! Assuming all the settings that worked for me, work for you, you should be able to login using OpenID Connect via Authelia. If you find any errors, it's a good idea to keep a document of all your settings from Authelia/Nextcloud/LLDAP etc so that you can easily reference and ensure everything lines up.
If you have any issues, please create a [discussion](https://github.com/nitnelave/lldap/discussions) or join the [Discord](https://discord.gg/h5PEdRMNyP).

56
example_configs/nexus.md Normal file
View File

@ -0,0 +1,56 @@
# Configuration for Sonatype Nexus Repository Manager 3
In Nexus log in as an administrator, go to `Server Administration and configuration (gear icon)`
Select `LDAP` under the `Security` section
Click `Create connection`
* Host: A name for the connection e.g. lldap
* Type: ldap
* Host: Your lldap server's ip/hostname
* Port: Your lldap server's port (3890 by default)
* Base DN: `dc=example,dc=com`
* Authentication Method: Simple Authentication
* Username or DN: `uid=admin,ou=people,dc=example,dc=com` or preferably create a read only user in lldap with the lldap_strict_readonly group.
* Password: The password for the user specified above
Click `Verify connection` if successful click `Next`
* Select a template: Generic ldap server
* User Relative DN: `ou=people`
* User subtree: Leave unchecked
* Object class: person
* User Filter: Leave empty to allow all users to log in or `(memberOf=uid=nexus_users,ou=groups,dc=example,dc=com)` for a specific group
* Username Attribute: `uid`
* Real Name Attribute: `cn`
* Email Attribute: `mail`
* Password Attribute: Leave blank
* Check `Enable User Synchronization`
Test user login credentials with `Verify login`
## Set up group mapping as roles
Check `Map LDAP groups as roles`
* Group Type: `Static Groups`
* Group relative DN: `ou=groups`
* Group subtree: Leave unchecked
* Group object class: `groupOfUniqueNames`
* Group ID attribute: `cn`
* Group member attribute: `member`
* Group member format: `uid=${username},ou=people,dc=example,dc=com`
Check user mapping with `Verify user mapping`
## Map specific roles to groups
In Nexus log in as an administrator, go to `Server Administration and configuration (gear icon)`
Select `Roles` under the `Security` section
Click `Create Role`
* Role ID: e.g. nexus_admin (name in nexus)
* Role Name: e.g. nexus_admin (group in lldap)
* Add privileges/roles as needed e.g. under Roles add nx-admin to the "contained" list
Click `Save`

View File

@ -0,0 +1,95 @@
# Configuration for SUSE Rancher (any version)
### Left (hamburger) menu > Users & Authentication > OpenLDAP (yes, we are using the OpenLDAP config page)
---
## LDAP configuration
#### Hostname/IP
```
ip-address, DNS name or when running in Kubernetes (see https://github.com/Evantage-WS/lldap-kubernetes), lldap-service.lldap.svc.cluster.local
```
#### Port
```
3890
```
#### Service Account Distinguished name
A better option is to use a readonly account for accessing the LLDAP server
```
cn=admin,ou=people,dc=example,dc=com
```
#### Service Account Password
```
xxx
```
#### User Search Base
```
ou=people,dc=example,dc=com
```
#### Group Search Base
```
ou=groups,dc=example,dc=com
```
#### Object Class (users)
```
inetOrgPerson
```
#### Object Class (groups)
```
groupOfUniqueNames
```
#### Username Attribute
```
uid
```
#### Name Attribute
```
cn
```
#### Login Attribute
```
uid
```
#### Group Member User Attribute
```
dn
```
#### User Member Attribute
```
memberOf
```
#### Search Attribute (groups)
```
cn
```
#### Search Attribute (users)
```
uid|sn|givenName
```
#### Group Member Mapping Attribute
```
member
```
#### Group DN Attribute
```
dn
```
##### Choose "Search direct and nested group memberships"
##### Fill in the username and password of an admin user at Test and Enable Authentication and hit save
## Rancher OpenLDAP config page
![Rancher OpenLDAP config page](images/rancher_ldap_config.png)

View File

@ -0,0 +1,11 @@
# Configuration for shaarli
LDAP configuration is in ```/data/config.json.php```
Just add the following lines:
```
"ldap": {
"host": "ldap://lldap_server:3890",
"dn": "uid=%s,ou=people,dc=example,dc=com"
}
```

View File

@ -0,0 +1,32 @@
# Configuration for Vaultwarden
https://github.com/ViViDboarder/vaultwarden_ldap will send an invitation to any member of the group `vaultwarden`.
Replace `dc=example,dc=com` with your LLDAP configured domain.
`docker-compose.yml` to run `vaultwarden_ldap`
```
version: '3'
services:
ldap_sync:
image: vividboarder/vaultwarden_ldap:0.6-alpine
volumes:
- ./config.toml:/config.toml:ro
environment:
CONFIG_PATH: /config.toml
RUST_BACKTRACE: 1
restart: always
```
Configuration to use LDAP in `config.toml`
```toml
vaultwarden_url = "http://your_bitwarden_url:port"
vaultwarden_admin_token = "insert_admin_token_vaultwarden"
ldap_host = "insert_ldap_host"
ldap_port = 3890
ldap_bind_dn = "uid=admin,ou=people,dc=example,dc=com"
ldap_bind_password = "insert_admin_pw_ldap"
ldap_search_base_dn = "dc=example,dc=com"
ldap_search_filter = "(&(objectClass=person)(memberOf=uid=vaultwarden,ou=groups,dc=example,dc=com))"
ldap_sync_interval_seconds = 300
```
Will check every 300 seconds your ldap group ```vaultwarden``` and send an invitation by email to any new member of this group.

39
example_configs/wekan.md Normal file
View File

@ -0,0 +1,39 @@
# Configuration for WeKan
WeKan provides quite sophisticated LDAP authentication.
Their wiki page is here: https://github.com/wekan/wekan/wiki/LDAP
Their Docker Compose file with all possible LDAP configuration values and their explanation is here: https://github.com/wekan/wekan/blob/master/docker-compose.yml
## Docker Sample Settings
Here is a working example for an LDAP confiuration via Docker Compose Environment variables:
```
environment:
# Other values for your WeKan installation
- ...
# LDAP Section
- DEFAULT_AUTHENTICATION_METHOD=ldap
- LDAP_ENABLE=true
- LDAP_PORT=3890
- LDAP_HOST=localhost
- LDAP_USER_AUTHENTICATION=true
- LDAP_USER_AUTHENTICATION_FIELD=uid
- LDAP_BASEDN=ou=people,dc=example,dc=com
- LDAP_RECONNECT=true
- LDAP_AUTHENTIFICATION=true
- LDAP_AUTHENTIFICATION_USERDN=uid=admin,ou=people,dc=example,dc=com
- LDAP_AUTHENTIFICATION_PASSWORD=replacewithyoursecret
- LDAP_LOG_ENABLED=true
# If using LDAPS: LDAP_ENCRYPTION=ssl
- LDAP_ENCRYPTION=false
# The certification for the LDAPS server. Certificate needs to be included in this docker-compose.yml file.
#- LDAP_CA_CERT=-----BEGIN CERTIFICATE-----MIIE+G2FIdAgIC...-----END CERTIFICATE-----
# Use this if you want to limit to a specific group
- LDAP_USER_SEARCH_FILTER=(&(objectClass=person)(memberof=cn=wekan_users,ou=groups,dc=example,dc=com))
- LDAP_USER_SEARCH_SCOPE=one
- LDAP_USER_SEARCH_FIELD=uid
- LDAP_USERNAME_FIELD=uid
- LDAP_FULLNAME_FIELD=cn
- LDAP_EMAIL_FIELD=mail
```

64
example_configs/wikijs.md Normal file
View File

@ -0,0 +1,64 @@
# Configuration for WikiJS
Replace `dc=example,dc=com` with your LLDAP configured domain.
### LDAP URL
```
ldap://lldap:3890
```
### Admin Bind DN
```
uid=admin,ou=people,dc=example,dc=com
```
or
```
uid=readonlyuser,ou=people,dc=example,dc=com
```
### Admin Bind Credentials
```
ADMINPASSWORD
```
or
```
READONLYUSERPASSWORD
```
### Search Base
```
ou=people,dc=example,dc=com
```
### Search Filter
If you wish the permitted users to be restricted to just the `wiki` group:
```
(&(memberof=cn=wiki,ou=groups,dc=example,dc=com)(|(uid={{username}})(mail={{username}))(objectClass=person))
```
If you wish any of the registered LLDAP users to be permitted to use WikiJS:
```
(&(|(uid={{username}})(mail={{username}))(objectClass=person))
```
### Use TLS
Left toggled off
### Verify TLS Certificate
Left toggled off
### TLS Certificate Path
Left blank
### Unique ID Field Mapping
```
uid
```
### Email Field Mapping
```
mail
```
### Display Name Field Mapping
```
givenname
```
### Avatar Picture Field Mapping
```
jpegPhoto
```
### Allow self-registration
Toggled on
### Limit to specific email domains
Left blank
### Assign to group
I created a group called `users` and assign my LDAP users to that by default.
You can use the local admin account to login and promote an LDAP user to `admin` group if you wish and then deactivate the local login option

18
example_configs/zendto.md Normal file
View File

@ -0,0 +1,18 @@
# Configuration for Zendto
You setup https://zend.to/ for using LDAP by editing `/opt/zendto/config/preferences.php`. The relevant part for LDAP-settings is
```
'authenticator' => 'LDAP',
'authLDAPBaseDN' => 'DC=example,DC=com',
'authLDAPServers' => array('ldap://ldap_server_ip:3890'),
'authLDAPAccountSuffix' => '@example.com',
'authLDAPUseSSL' => false,
'authLDAPStartTLS' => false,
'authLDAPBindDn' => 'uid=admin,ou=people,dc=example,dc=com',
'authLDAPBindPass' => 'your_password',
'authLDAPUsernameAttr' => 'uid',
'authLDAPEmailAttr' => 'mail',
'authLDAPMemberKey' => 'memberOf',
'authLDAPMemberRole' => 'uid=zendto,ou=groups,dc=example,dc=com',
```
Every user of the group `zendto` is allowed to login.

View File

@ -7,9 +7,21 @@
## You can set it with the LLDAP_VERBOSE environment variable.
# verbose=false
## The host address that the LDAP server will be bound to.
## To enable IPv6 support, simply switch "ldap_host" to "::":
## To only allow connections from localhost (if you want to restrict to local self-hosted services),
## change it to "127.0.0.1" ("::1" in case of IPv6)".
#ldap_host = "0.0.0.0"
## The port on which to have the LDAP server.
#ldap_port = 3890
## The host address that the HTTP server will be bound to.
## To enable IPv6 support, simply switch "http_host" to "::".
## To only allow connections from localhost (if you want to restrict to local self-hosted services),
## change it to "127.0.0.1" ("::1" in case of IPv6)".
#http_host = "0.0.0.0"
## The port on which to have the HTTP server, for user login and
## administration.
#http_port = 17170
@ -27,7 +39,7 @@
## This can also be set from a file's contents by specifying the file path
## in the LLDAP_JWT_SECRET_FILE environment variable
## You can generate it with (on linux):
## LC_ALL=C tr -dc 'A-Za-z0-9!"#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
## LC_ALL=C tr -dc 'A-Za-z0-9!#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
#jwt_secret = "REPLACE_WITH_RANDOM"
## Base DN for LDAP.
@ -63,16 +75,16 @@
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
## Database URL.
## This encodes the type of database (SQlite, Mysql and so
## on), the path, the user, password, and sometimes the mode (when
## This encodes the type of database (SQlite, MySQL, or PostgreSQL)
## , the path, the user, password, and sometimes the mode (when
## relevant).
## Note: Currently, only SQlite is supported. SQlite should come with
## "?mode=rwc" to create the DB if not present.
## Note: SQlite should come with "?mode=rwc" to create the DB
## if not present.
## Example URLs:
## - "postgres://postgres-user:password@postgres-server/my-database"
## - "mysql://mysql-user:password@mysql-server/my-database"
##
## This can be overridden with the DATABASE_URL env variable.
## This can be overridden with the LLDAP_DATABASE_URL env variable.
database_url = "sqlite:///data/users.db?mode=rwc"
## Private key file.
@ -101,7 +113,7 @@ key_file = "/data/private_key"
#server="smtp.gmail.com"
## The SMTP port.
#port=587
## How the connection is encrypted, either "TLS" or "STARTTLS".
## How the connection is encrypted, either "NONE" (no encryption), "TLS" or "STARTTLS".
#smtp_encryption = "TLS"
## The SMTP user, usually your email address.
#user="sender@gmail.com"

View File

@ -1,6 +1,6 @@
[package]
name = "migration-tool"
version = "0.4.1"
version = "0.4.2"
edition = "2021"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]

View File

@ -1,3 +1,5 @@
#![allow(clippy::uninlined_format_args)]
use std::collections::HashSet;
use anyhow::{anyhow, Result};

2
schema.graphql generated
View File

@ -69,7 +69,7 @@ type User {
displayName: String!
firstName: String!
lastName: String!
avatar: String!
avatar: String
creationDate: DateTimeUtc!
uuid: String!
"The groups to which this user belongs."

View File

@ -0,0 +1,9 @@
#! /bin/bash
tables=("users" "groups" "memberships" "jwt_refresh_storage" "jwt_storage" "password_reset_tokens")
echo ".header on"
for table in ${tables[@]}; do
echo ".mode insert $table"
echo "select * from $table;"
done

View File

@ -2,51 +2,51 @@
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
name = "lldap"
version = "0.4.1"
version = "0.4.4-alpha"
[dependencies]
actix = "0.12"
actix-files = "0.6.0-beta.6"
actix-http = "=3.0.0-beta.9"
actix-rt = "2.2.0"
actix-server = "=2.0.0-beta.5"
actix-service = "2.0.0"
actix-web = "=4.0.0-beta.8"
actix-web-httpauth = "0.6.0-beta.2"
actix = "0.13"
actix-files = "0.6"
actix-http = "3"
actix-rt = "2"
actix-server = "2"
actix-service = "2"
actix-web = "4.3"
actix-web-httpauth = "0.8"
anyhow = "*"
async-trait = "0.1"
base64 = "0.13"
base64 = "0.21"
bincode = "1.3"
cron = "*"
derive_builder = "0.10.2"
derive_builder = "0.12"
figment_file_provider_adapter = "0.1"
futures = "*"
futures-util = "*"
hmac = "0.10"
hmac = "0.12"
http = "*"
itertools = "0.10.1"
juniper = "0.15.10"
juniper_actix = "0.4.0"
jwt = "0.13"
ldap3_proto = "*"
itertools = "0.10"
juniper = "0.15"
jwt = "0.16"
lber = "0.4.1"
ldap3_proto = ">=0.3.1"
log = "*"
orion = "0.16"
rustls = "0.20"
orion = "0.17"
rustls-pemfile = "1"
serde = "*"
serde_bytes = "0.11"
serde_json = "1"
sha2 = "0.9"
sqlx-core = "0.5.11"
sha2 = "0.10"
thiserror = "*"
time = "0.2"
time = "0.3"
tokio-rustls = "0.23"
tokio-stream = "*"
tokio-util = "0.7.3"
tokio-util = "0.7"
tracing = "*"
tracing-actix-web = "0.4.0-beta.7"
tracing-actix-web = "0.7"
tracing-attributes = "^0.1.21"
tracing-log = "*"
rustls-pemfile = "1.0.0"
serde_bytes = "0.11.7"
urlencoding = "2"
webpki-roots = "*"
[dependencies.chrono]
features = ["serde"]
@ -54,7 +54,7 @@ version = "*"
[dependencies.clap]
features = ["std", "color", "suggestions", "derive", "env"]
version = "3.1.15"
version = "4"
[dependencies.figment]
features = ["env", "toml"]
@ -67,31 +67,11 @@ features = ["env-filter", "tracing-log"]
[dependencies.lettre]
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
default-features = false
version = "0.10.0-rc.3"
[dependencies.sqlx]
version = "0.5.11"
features = [
"any",
"chrono",
"macros",
"mysql",
"postgres",
"runtime-actix-rustls",
"sqlite",
]
version = "0.10.1"
[dependencies.lldap_auth]
path = "../auth"
[dependencies.sea-query]
version = "^0.25"
features = ["with-chrono", "sqlx-sqlite"]
[dependencies.sea-query-binder]
version = "0.1"
features = ["with-chrono", "sqlx-sqlite", "sqlx-any"]
[dependencies.opaque-ke]
version = "0.6"
@ -105,7 +85,7 @@ version = "*"
[dependencies.tokio]
features = ["full"]
version = "1.17"
version = "1.25"
[dependencies.uuid]
features = ["v3"]
@ -117,12 +97,26 @@ version = "^0.1.4"
[dependencies.actix-tls]
features = ["default", "rustls"]
version = "=3.0.0-beta.5"
version = "3"
[dependencies.image]
features = ["jpeg"]
default-features = false
version = "0.24"
[dependencies.sea-orm]
version= "0.11"
default-features = false
features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"]
[dependencies.reqwest]
version = "0.11"
default-features = false
features = ["rustls-tls-webpki-roots"]
[dependencies.rustls]
version = "0.20"
features = ["dangerous_configuration"]
[dev-dependencies]
mockall = "0.9.1"
mockall = "0.11"

View File

@ -6,7 +6,7 @@ pub enum DomainError {
#[error("Authentication error: `{0}`")]
AuthenticationError(String),
#[error("Database error: `{0}`")]
DatabaseError(#[from] sqlx::Error),
DatabaseError(#[from] sea_orm::DbErr),
#[error("Authentication protocol error for `{0}`")]
AuthenticationProtocolError(#[from] lldap_auth::opaque::AuthenticationError),
#[error("Unknown crypto error: `{0}`")]
@ -15,6 +15,8 @@ pub enum DomainError {
BinarySerializationError(#[from] bincode::Error),
#[error("Invalid base64: `{0}`")]
Base64DecodeError(#[from] base64::DecodeError),
#[error("Entity not found: `{0}`")]
EntityNotFound(String),
#[error("Internal error: `{0}`")]
InternalError(String),
}

View File

@ -1,225 +1,98 @@
use super::error::*;
use super::{
error::Result,
types::{
Group, GroupDetails, GroupId, JpegPhoto, User, UserAndGroups, UserColumn, UserId, Uuid,
},
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(
PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::FromRow, sqlx::Type,
)]
#[serde(try_from = "&str")]
#[sqlx(transparent)]
pub struct Uuid(String);
impl Uuid {
pub fn from_name_and_date(name: &str, creation_date: &chrono::DateTime<chrono::Utc>) -> Self {
Uuid(
uuid::Uuid::new_v3(
&uuid::Uuid::NAMESPACE_X500,
&[name.as_bytes(), creation_date.to_rfc3339().as_bytes()].concat(),
)
.to_string(),
)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl<'a> std::convert::TryFrom<&'a str> for Uuid {
type Error = anyhow::Error;
fn try_from(s: &'a str) -> anyhow::Result<Self> {
Ok(Uuid(uuid::Uuid::parse_str(s)?.to_string()))
}
}
impl std::string::ToString for Uuid {
fn to_string(&self) -> String {
self.0.clone()
}
}
#[cfg(test)]
#[macro_export]
macro_rules! uuid {
($s:literal) => {
$crate::domain::handler::Uuid::try_from($s).unwrap()
};
}
#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)]
#[serde(from = "String")]
#[sqlx(transparent)]
pub struct UserId(String);
impl UserId {
pub fn new(user_id: &str) -> Self {
Self(user_id.to_lowercase())
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn into_string(self) -> String {
self.0
}
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for UserId {
fn from(s: String) -> Self {
Self::new(&s)
}
}
#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct JpegPhoto(#[serde(with = "serde_bytes")] Vec<u8>);
impl From<JpegPhoto> for sea_query::Value {
fn from(photo: JpegPhoto) -> Self {
photo.0.into()
}
}
impl From<&JpegPhoto> for sea_query::Value {
fn from(photo: &JpegPhoto) -> Self {
photo.0.as_slice().into()
}
}
impl TryFrom<Vec<u8>> for JpegPhoto {
type Error = anyhow::Error;
fn try_from(bytes: Vec<u8>) -> anyhow::Result<Self> {
// Confirm that it's a valid Jpeg, then store only the bytes.
image::io::Reader::with_format(
std::io::Cursor::new(bytes.as_slice()),
image::ImageFormat::Jpeg,
)
.decode()?;
Ok(JpegPhoto(bytes))
}
}
impl TryFrom<String> for JpegPhoto {
type Error = anyhow::Error;
fn try_from(string: String) -> anyhow::Result<Self> {
// The String format is in base64.
Self::try_from(base64::decode(string.as_str())?)
}
}
impl From<&JpegPhoto> for String {
fn from(val: &JpegPhoto) -> Self {
base64::encode(&val.0)
}
}
impl JpegPhoto {
pub fn into_bytes(self) -> Vec<u8> {
self.0
}
#[cfg(test)]
pub fn for_tests() -> Self {
use image::{ImageOutputFormat, Rgb, RgbImage};
let img = RgbImage::from_fn(32, 32, |x, y| {
if (x + y) % 2 == 0 {
Rgb([0, 0, 0])
} else {
Rgb([255, 255, 255])
}
});
let mut bytes: Vec<u8> = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut bytes),
ImageOutputFormat::Jpeg(0),
)
.unwrap();
Self(bytes)
}
}
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub user_id: UserId,
pub email: String,
pub display_name: String,
pub first_name: String,
pub last_name: String,
pub avatar: JpegPhoto,
pub creation_date: chrono::DateTime<chrono::Utc>,
pub uuid: Uuid,
}
#[cfg(test)]
impl Default for User {
fn default() -> Self {
use chrono::TimeZone;
let epoch = chrono::Utc.timestamp(0, 0);
User {
user_id: UserId::default(),
email: String::new(),
display_name: String::new(),
first_name: String::new(),
last_name: String::new(),
avatar: JpegPhoto::default(),
creation_date: epoch,
uuid: Uuid::from_name_and_date("", &epoch),
}
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Group {
pub id: GroupId,
pub display_name: String,
pub creation_date: chrono::DateTime<chrono::Utc>,
pub uuid: Uuid,
pub users: Vec<UserId>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct BindRequest {
pub name: UserId,
pub password: String,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct SubStringFilter {
pub initial: Option<String>,
pub any: Vec<String>,
pub final_: Option<String>,
}
impl SubStringFilter {
pub fn to_sql_filter(&self) -> String {
let mut filter = String::with_capacity(
self.initial.as_ref().map(String::len).unwrap_or_default()
+ 1
+ self.any.iter().map(String::len).sum::<usize>()
+ self.any.len()
+ self.final_.as_ref().map(String::len).unwrap_or_default(),
);
if let Some(f) = &self.initial {
filter.push_str(&f.to_ascii_lowercase());
}
filter.push('%');
for part in self.any.iter() {
filter.push_str(&part.to_ascii_lowercase());
filter.push('%');
}
if let Some(f) = &self.final_ {
filter.push_str(&f.to_ascii_lowercase());
}
filter
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub enum UserRequestFilter {
And(Vec<UserRequestFilter>),
Or(Vec<UserRequestFilter>),
Not(Box<UserRequestFilter>),
UserId(UserId),
Equality(String, String),
UserIdSubString(SubStringFilter),
Equality(UserColumn, String),
SubString(UserColumn, SubStringFilter),
// Check if a user belongs to a group identified by name.
MemberOf(String),
// Same, by id.
MemberOfId(GroupId),
}
impl From<bool> for UserRequestFilter {
fn from(val: bool) -> Self {
if val {
Self::And(vec![])
} else {
Self::Not(Box::new(Self::And(vec![])))
}
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub enum GroupRequestFilter {
And(Vec<GroupRequestFilter>),
Or(Vec<GroupRequestFilter>),
Not(Box<GroupRequestFilter>),
DisplayName(String),
DisplayNameSubString(SubStringFilter),
Uuid(Uuid),
GroupId(GroupId),
// Check if the group contains a user identified by uid.
Member(UserId),
}
impl From<bool> for GroupRequestFilter {
fn from(val: bool) -> Self {
if val {
Self::And(vec![])
} else {
Self::Not(Box::new(Self::And(vec![])))
}
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct CreateUserRequest {
// Same fields as User, but no creation_date, and with password.
@ -249,49 +122,54 @@ pub struct UpdateGroupRequest {
}
#[async_trait]
pub trait LoginHandler: Clone + Send {
pub trait LoginHandler: Send + Sync {
async fn bind(&self, request: BindRequest) -> Result<()>;
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct GroupId(pub i32);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::FromRow)]
pub struct GroupDetails {
pub group_id: GroupId,
pub display_name: String,
pub creation_date: chrono::DateTime<chrono::Utc>,
pub uuid: Uuid,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserAndGroups {
pub user: User,
pub groups: Option<Vec<GroupDetails>>,
#[async_trait]
pub trait GroupListerBackendHandler {
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
}
#[async_trait]
pub trait BackendHandler: Clone + Send {
pub trait GroupBackendHandler {
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
}
#[async_trait]
pub trait UserListerBackendHandler {
async fn list_users(
&self,
filters: Option<UserRequestFilter>,
get_groups: bool,
) -> Result<Vec<UserAndGroups>>;
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
}
#[async_trait]
pub trait UserBackendHandler {
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
}
#[async_trait]
pub trait BackendHandler:
Send
+ Sync
+ GroupBackendHandler
+ UserBackendHandler
+ UserListerBackendHandler
+ GroupListerBackendHandler
{
}
#[cfg(test)]
mockall::mock! {
pub TestBackendHandler{}
@ -299,22 +177,33 @@ mockall::mock! {
fn clone(&self) -> Self;
}
#[async_trait]
impl BackendHandler for TestBackendHandler {
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
impl GroupListerBackendHandler for TestBackendHandler {
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
}
#[async_trait]
impl GroupBackendHandler for TestBackendHandler {
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
}
#[async_trait]
impl UserListerBackendHandler for TestBackendHandler {
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
}
#[async_trait]
impl UserBackendHandler for TestBackendHandler {
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
}
#[async_trait]
impl BackendHandler for TestBackendHandler {}
#[async_trait]
impl LoginHandler for TestBackendHandler {
async fn bind(&self, request: BindRequest) -> Result<()>;
}
@ -322,13 +211,21 @@ mockall::mock! {
#[cfg(test)]
mod tests {
use base64::Engine;
use super::*;
#[test]
fn test_uuid_time() {
use chrono::prelude::*;
let user_id = "bob";
let date1 = Utc.ymd(2014, 7, 8).and_hms(9, 10, 11);
let date2 = Utc.ymd(2014, 7, 8).and_hms(9, 10, 12);
let date1 = Utc
.with_ymd_and_hms(2014, 7, 8, 9, 10, 11)
.unwrap()
.naive_utc();
let date2 = Utc
.with_ymd_and_hms(2014, 7, 8, 9, 10, 12)
.unwrap()
.naive_utc();
assert_ne!(
Uuid::from_name_and_date(user_id, &date1),
Uuid::from_name_and_date(user_id, &date2)
@ -338,7 +235,9 @@ mod tests {
#[test]
fn test_jpeg_try_from_bytes() {
let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k=";
let base64_jpeg = base64::decode(base64_raw).unwrap();
let base64_jpeg = base64::engine::general_purpose::STANDARD
.decode(base64_raw)
.unwrap();
JpegPhoto::try_from(base64_jpeg).unwrap();
}
}

View File

@ -0,0 +1,17 @@
use ldap3_proto::LdapResultCode;
#[derive(Debug, PartialEq)]
pub struct LdapError {
pub code: LdapResultCode,
pub message: String,
}
impl std::fmt::Display for LdapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for LdapError {}
pub type LdapResult<T> = std::result::Result<T, LdapError>;

View File

@ -0,0 +1,237 @@
use ldap3_proto::{
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
};
use tracing::{debug, instrument, warn};
use crate::domain::{
handler::{GroupListerBackendHandler, GroupRequestFilter},
ldap::error::LdapError,
types::{Group, GroupColumn, UserId, Uuid},
};
use super::{
error::LdapResult,
utils::{
expand_attribute_wildcards, get_group_id_from_distinguished_name,
get_user_id_from_distinguished_name, map_group_field, LdapInfo,
},
};
pub fn get_group_attribute(
group: &Group,
base_dn_str: &str,
attribute: &str,
user_filter: &Option<UserId>,
ignored_group_attributes: &[String],
) -> Option<Vec<Vec<u8>>> {
let attribute = attribute.to_ascii_lowercase();
let attribute_values = match attribute.as_str() {
"objectclass" => vec![b"groupOfUniqueNames".to_vec()],
// Always returned as part of the base response.
"dn" | "distinguishedname" => return None,
"cn" | "uid" | "id" => vec![group.display_name.clone().into_bytes()],
"entryuuid" | "uuid" => vec![group.uuid.to_string().into_bytes()],
"member" | "uniquemember" => group
.users
.iter()
.filter(|u| user_filter.as_ref().map(|f| *u == f).unwrap_or(true))
.map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes())
.collect(),
"1.1" => return None,
// We ignore the operational attribute wildcard
"+" => return None,
"*" => {
panic!(
"Matched {}, * should have been expanded into attribute list and * removed",
attribute
)
}
_ => {
if !ignored_group_attributes.contains(&attribute) {
warn!(
r#"Ignoring unrecognized group attribute: {}\n\
To disable this warning, add it to "ignored_group_attributes" in the config."#,
attribute
);
}
return None;
}
};
if attribute_values.len() == 1 && attribute_values[0].is_empty() {
None
} else {
Some(attribute_values)
}
}
const ALL_GROUP_ATTRIBUTE_KEYS: &[&str] = &[
"objectclass",
"uid",
"cn",
"member",
"uniquemember",
"entryuuid",
];
fn expand_group_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS)
}
fn make_ldap_search_group_result_entry(
group: Group,
base_dn_str: &str,
attributes: &[String],
user_filter: &Option<UserId>,
ignored_group_attributes: &[String],
) -> LdapSearchResultEntry {
let expanded_attributes = expand_group_attribute_wildcards(attributes);
LdapSearchResultEntry {
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
attributes: expanded_attributes
.iter()
.filter_map(|a| {
let values = get_group_attribute(
&group,
base_dn_str,
a,
user_filter,
ignored_group_attributes,
)?;
Some(LdapPartialAttribute {
atype: a.to_string(),
vals: values,
})
})
.collect::<Vec<LdapPartialAttribute>>(),
}
}
fn convert_group_filter(
ldap_info: &LdapInfo,
filter: &LdapFilter,
) -> LdapResult<GroupRequestFilter> {
let rec = |f| convert_group_filter(ldap_info, f);
match filter {
LdapFilter::Equality(field, value) => {
let field = &field.to_ascii_lowercase();
let value = &value.to_ascii_lowercase();
match field.as_str() {
"member" | "uniquemember" => {
let user_name = get_user_id_from_distinguished_name(
value,
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)?;
Ok(GroupRequestFilter::Member(user_name))
}
"objectclass" => Ok(GroupRequestFilter::from(matches!(
value.as_str(),
"groupofuniquenames" | "groupofnames"
))),
"dn" => Ok(get_group_id_from_distinguished_name(
value.to_ascii_lowercase().as_str(),
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)
.map(GroupRequestFilter::DisplayName)
.unwrap_or_else(|_| {
warn!("Invalid dn filter on group: {}", value);
GroupRequestFilter::from(false)
})),
_ => match map_group_field(field) {
Some(GroupColumn::DisplayName) => {
Ok(GroupRequestFilter::DisplayName(value.to_string()))
}
Some(GroupColumn::Uuid) => Ok(GroupRequestFilter::Uuid(
Uuid::try_from(value.as_str()).map_err(|e| LdapError {
code: LdapResultCode::InappropriateMatching,
message: format!("Invalid UUID: {:#}", e),
})?,
)),
_ => {
if !ldap_info.ignored_group_attributes.contains(field) {
warn!(
r#"Ignoring unknown group attribute "{:?}" in filter.\n\
To disable this warning, add it to "ignored_group_attributes" in the config."#,
field
);
}
Ok(GroupRequestFilter::from(false))
}
},
}
}
LdapFilter::And(filters) => Ok(GroupRequestFilter::And(
filters.iter().map(rec).collect::<LdapResult<_>>()?,
)),
LdapFilter::Or(filters) => Ok(GroupRequestFilter::Or(
filters.iter().map(rec).collect::<LdapResult<_>>()?,
)),
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
LdapFilter::Present(field) => {
let field = &field.to_ascii_lowercase();
Ok(GroupRequestFilter::from(
field == "objectclass"
|| field == "dn"
|| field == "distinguishedname"
|| map_group_field(field).is_some(),
))
}
LdapFilter::Substring(field, substring_filter) => {
let field = &field.to_ascii_lowercase();
match map_group_field(field.as_str()) {
Some(GroupColumn::DisplayName) => Ok(GroupRequestFilter::DisplayNameSubString(
substring_filter.clone().into(),
)),
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
"Unsupported group attribute for substring filter: {:?}",
field
),
}),
}
}
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!("Unsupported group filter: {:?}", filter),
}),
}
}
#[instrument(skip_all, level = "debug")]
pub async fn get_groups_list<Backend: GroupListerBackendHandler>(
ldap_info: &LdapInfo,
ldap_filter: &LdapFilter,
base: &str,
backend: &Backend,
) -> LdapResult<Vec<Group>> {
debug!(?ldap_filter);
let filters = convert_group_filter(ldap_info, ldap_filter)?;
debug!(?filters);
backend
.list_groups(Some(filters))
.await
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!(r#"Error while listing groups "{}": {:#}"#, base, e),
})
}
pub fn convert_groups_to_ldap_op<'a>(
groups: Vec<Group>,
attributes: &'a [String],
ldap_info: &'a LdapInfo,
user_filter: &'a Option<UserId>,
) -> impl Iterator<Item = LdapOp> + 'a {
groups.into_iter().map(move |g| {
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
g,
&ldap_info.base_dn_str,
attributes,
user_filter,
&ldap_info.ignored_group_attributes,
))
})
}

View File

@ -0,0 +1,4 @@
pub mod error;
pub mod group;
pub mod user;
pub mod utils;

View File

@ -0,0 +1,249 @@
use chrono::TimeZone;
use ldap3_proto::{
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
};
use tracing::{debug, instrument, warn};
use crate::domain::{
handler::{UserListerBackendHandler, UserRequestFilter},
ldap::{
error::LdapError,
utils::{expand_attribute_wildcards, get_user_id_from_distinguished_name},
},
types::{GroupDetails, User, UserAndGroups, UserColumn, UserId},
};
use super::{
error::LdapResult,
utils::{get_group_id_from_distinguished_name, map_user_field, LdapInfo},
};
pub fn get_user_attribute(
user: &User,
attribute: &str,
base_dn_str: &str,
groups: Option<&[GroupDetails]>,
ignored_user_attributes: &[String],
) -> Option<Vec<Vec<u8>>> {
let attribute = attribute.to_ascii_lowercase();
let attribute_values = match attribute.as_str() {
"objectclass" => vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
],
// dn is always returned as part of the base response.
"dn" | "distinguishedname" => return None,
"uid" | "user_id" | "id" => vec![user.user_id.to_string().into_bytes()],
"entryuuid" | "uuid" => vec![user.uuid.to_string().into_bytes()],
"mail" | "email" => vec![user.email.clone().into_bytes()],
"givenname" | "first_name" | "firstname" => vec![user.first_name.clone()?.into_bytes()],
"sn" | "last_name" | "lastname" => vec![user.last_name.clone()?.into_bytes()],
"jpegphoto" | "avatar" => vec![user.avatar.clone()?.into_bytes()],
"memberof" => groups
.into_iter()
.flatten()
.map(|id_and_name| {
format!("cn={},ou=groups,{}", &id_and_name.display_name, base_dn_str).into_bytes()
})
.collect(),
"cn" | "displayname" => vec![user.display_name.clone()?.into_bytes()],
"creationdate" | "creation_date" | "createtimestamp" | "modifytimestamp" => {
vec![chrono::Utc
.from_utc_datetime(&user.creation_date)
.to_rfc3339()
.into_bytes()]
}
"1.1" => return None,
// We ignore the operational attribute wildcard.
"+" => return None,
"*" => {
panic!(
"Matched {}, * should have been expanded into attribute list and * removed",
attribute
)
}
_ => {
if !ignored_user_attributes.contains(&attribute) {
warn!(
r#"Ignoring unrecognized group attribute: {}\n\
To disable this warning, add it to "ignored_user_attributes" in the config."#,
attribute
);
}
return None;
}
};
if attribute_values.len() == 1 && attribute_values[0].is_empty() {
None
} else {
Some(attribute_values)
}
}
const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[
"objectclass",
"uid",
"mail",
"givenname",
"sn",
"cn",
"jpegPhoto",
"createtimestamp",
"entryuuid",
];
fn make_ldap_search_user_result_entry(
user: User,
base_dn_str: &str,
attributes: &[String],
groups: Option<&[GroupDetails]>,
ignored_user_attributes: &[String],
) -> LdapSearchResultEntry {
let expanded_attributes = expand_user_attribute_wildcards(attributes);
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
LdapSearchResultEntry {
dn,
attributes: expanded_attributes
.iter()
.filter_map(|a| {
let values =
get_user_attribute(&user, a, base_dn_str, groups, ignored_user_attributes)?;
Some(LdapPartialAttribute {
atype: a.to_string(),
vals: values,
})
})
.collect::<Vec<LdapPartialAttribute>>(),
}
}
fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<UserRequestFilter> {
let rec = |f| convert_user_filter(ldap_info, f);
match filter {
LdapFilter::And(filters) => Ok(UserRequestFilter::And(
filters.iter().map(rec).collect::<LdapResult<_>>()?,
)),
LdapFilter::Or(filters) => Ok(UserRequestFilter::Or(
filters.iter().map(rec).collect::<LdapResult<_>>()?,
)),
LdapFilter::Not(filter) => Ok(UserRequestFilter::Not(Box::new(rec(filter)?))),
LdapFilter::Equality(field, value) => {
let field = &field.to_ascii_lowercase();
match field.as_str() {
"memberof" => Ok(UserRequestFilter::MemberOf(
get_group_id_from_distinguished_name(
&value.to_ascii_lowercase(),
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)?,
)),
"objectclass" => Ok(UserRequestFilter::from(matches!(
value.to_ascii_lowercase().as_str(),
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
))),
"dn" => Ok(get_user_id_from_distinguished_name(
value.to_ascii_lowercase().as_str(),
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)
.map(UserRequestFilter::UserId)
.unwrap_or_else(|_| {
warn!("Invalid dn filter on user: {}", value);
UserRequestFilter::from(false)
})),
_ => match map_user_field(field) {
Some(UserColumn::UserId) => Ok(UserRequestFilter::UserId(UserId::new(value))),
Some(field) => Ok(UserRequestFilter::Equality(field, value.clone())),
None => {
if !ldap_info.ignored_user_attributes.contains(field) {
warn!(
r#"Ignoring unknown user attribute "{}" in filter.\n\
To disable this warning, add it to "ignored_user_attributes" in the config"#,
field
);
}
Ok(UserRequestFilter::from(false))
}
},
}
}
LdapFilter::Present(field) => {
let field = &field.to_ascii_lowercase();
// Check that it's a field we support.
Ok(UserRequestFilter::from(
field == "objectclass"
|| field == "dn"
|| field == "distinguishedname"
|| map_user_field(field).is_some(),
))
}
LdapFilter::Substring(field, substring_filter) => {
let field = &field.to_ascii_lowercase();
match map_user_field(field.as_str()) {
Some(UserColumn::UserId) => Ok(UserRequestFilter::UserIdSubString(
substring_filter.clone().into(),
)),
None
| Some(UserColumn::CreationDate)
| Some(UserColumn::Avatar)
| Some(UserColumn::Uuid) => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
"Unsupported user attribute for substring filter: {:?}",
field
),
}),
Some(field) => Ok(UserRequestFilter::SubString(
field,
substring_filter.clone().into(),
)),
}
}
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!("Unsupported user filter: {:?}", filter),
}),
}
}
fn expand_user_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS)
}
#[instrument(skip_all, level = "debug")]
pub async fn get_user_list<Backend: UserListerBackendHandler>(
ldap_info: &LdapInfo,
ldap_filter: &LdapFilter,
request_groups: bool,
base: &str,
backend: &Backend,
) -> LdapResult<Vec<UserAndGroups>> {
debug!(?ldap_filter);
let filters = convert_user_filter(ldap_info, ldap_filter)?;
debug!(?filters);
backend
.list_users(Some(filters), request_groups)
.await
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!(r#"Error while searching user "{}": {:#}"#, base, e),
})
}
pub fn convert_users_to_ldap_op<'a>(
users: Vec<UserAndGroups>,
attributes: &'a [String],
ldap_info: &'a LdapInfo,
) -> impl Iterator<Item = LdapOp> + 'a {
users.into_iter().map(move |u| {
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
u.user,
&ldap_info.base_dn_str,
attributes,
u.groups.as_deref(),
&ldap_info.ignored_user_attributes,
))
})
}

View File

@ -0,0 +1,189 @@
use itertools::Itertools;
use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode};
use tracing::{debug, instrument, warn};
use crate::domain::{
handler::SubStringFilter,
ldap::error::{LdapError, LdapResult},
types::{GroupColumn, UserColumn, UserId},
};
impl From<LdapSubstringFilter> for SubStringFilter {
fn from(
LdapSubstringFilter {
initial,
any,
final_,
}: LdapSubstringFilter,
) -> Self {
Self {
initial,
any,
final_,
}
}
}
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
where
I: Iterator<Item = String>,
{
(|| {
let pair = (
iter.next().ok_or_else(|| "Empty DN element".to_string())?,
iter.next().ok_or_else(|| "Missing DN value".to_string())?,
);
if let Some(e) = iter.next() {
Err(format!(
r#"Too many elements in distinguished name: "{:?}", "{:?}", "{:?}""#,
pair.0, pair.1, e
))
} else {
Ok(pair)
}
})()
.map_err(|s| LdapError {
code: LdapResultCode::InvalidDNSyntax,
message: s,
})
}
pub fn parse_distinguished_name(dn: &str) -> LdapResult<Vec<(String, String)>> {
assert!(dn == dn.to_ascii_lowercase());
dn.split(',')
.map(|s| make_dn_pair(s.split('=').map(str::trim).map(String::from)))
.collect()
}
fn get_id_from_distinguished_name(
dn: &str,
base_tree: &[(String, String)],
base_dn_str: &str,
is_group: bool,
) -> LdapResult<String> {
let parts = parse_distinguished_name(dn)?;
{
let ou = if is_group { "groups" } else { "people" };
if !is_subtree(&parts, base_tree) {
Err("Not a subtree of the base tree".to_string())
} else if parts.len() == base_tree.len() + 2 {
if parts[1].0 != "ou" || parts[1].1 != ou || (parts[0].0 != "cn" && parts[0].0 != "uid")
{
Err(format!(
r#"Unexpected DN format. Got "{}", expected: "uid=id,ou={},{}""#,
dn, ou, base_dn_str
))
} else {
Ok(parts[0].1.to_string())
}
} else {
Err(format!(
r#"Unexpected DN format. Got "{}", expected: "uid=id,ou={},{}""#,
dn, ou, base_dn_str
))
}
}
.map_err(|s| LdapError {
code: LdapResultCode::InvalidDNSyntax,
message: s,
})
}
pub fn get_user_id_from_distinguished_name(
dn: &str,
base_tree: &[(String, String)],
base_dn_str: &str,
) -> LdapResult<UserId> {
get_id_from_distinguished_name(dn, base_tree, base_dn_str, false).map(UserId::from)
}
pub fn get_group_id_from_distinguished_name(
dn: &str,
base_tree: &[(String, String)],
base_dn_str: &str,
) -> LdapResult<String> {
get_id_from_distinguished_name(dn, base_tree, base_dn_str, true)
}
#[instrument(skip_all, level = "debug")]
pub fn expand_attribute_wildcards<'a>(
ldap_attributes: &'a [String],
all_attribute_keys: &'a [&'static str],
) -> Vec<&'a str> {
let mut attributes_out = ldap_attributes
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
if attributes_out.iter().any(|&x| x == "*") || attributes_out.is_empty() {
// Remove occurrences of '*'
attributes_out.retain(|&x| x != "*");
// Splice in all non-operational attributes
attributes_out.extend(all_attribute_keys.iter());
}
// Deduplicate, preserving order
let resolved_attributes = attributes_out
.into_iter()
.unique_by(|a| a.to_ascii_lowercase())
.collect_vec();
debug!(?ldap_attributes, ?resolved_attributes);
resolved_attributes
}
pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)]) -> bool {
for (k, v) in subtree {
assert!(k == &k.to_ascii_lowercase());
assert!(v == &v.to_ascii_lowercase());
}
for (k, v) in base_tree {
assert!(k == &k.to_ascii_lowercase());
assert!(v == &v.to_ascii_lowercase());
}
if subtree.len() < base_tree.len() {
return false;
}
let size_diff = subtree.len() - base_tree.len();
for i in 0..base_tree.len() {
if subtree[size_diff + i] != base_tree[i] {
return false;
}
}
true
}
pub fn map_user_field(field: &str) -> Option<UserColumn> {
assert!(field == field.to_ascii_lowercase());
Some(match field {
"uid" | "user_id" | "id" => UserColumn::UserId,
"mail" | "email" => UserColumn::Email,
"cn" | "displayname" | "display_name" => UserColumn::DisplayName,
"givenname" | "first_name" | "firstname" => UserColumn::FirstName,
"sn" | "last_name" | "lastname" => UserColumn::LastName,
"avatar" | "jpegphoto" => UserColumn::Avatar,
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
UserColumn::CreationDate
}
"entryuuid" | "uuid" => UserColumn::Uuid,
_ => return None,
})
}
pub fn map_group_field(field: &str) -> Option<GroupColumn> {
assert!(field == field.to_ascii_lowercase());
Some(match field {
"cn" | "displayname" | "uid" | "display_name" => GroupColumn::DisplayName,
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
GroupColumn::CreationDate
}
"entryuuid" | "uuid" => GroupColumn::Uuid,
_ => return None,
})
}
pub struct LdapInfo {
pub base_dn: Vec<(String, String)>,
pub base_dn_str: String,
pub ignored_user_attributes: Vec<String>,
pub ignored_group_attributes: Vec<String>,
}

View File

@ -1,6 +1,12 @@
pub mod error;
pub mod handler;
pub mod ldap;
pub mod model;
pub mod opaque_handler;
pub mod sql_backend_handler;
pub mod sql_group_backend_handler;
pub mod sql_migrations;
pub mod sql_opaque_handler;
pub mod sql_tables;
pub mod sql_user_backend_handler;
pub mod types;

View File

@ -0,0 +1,53 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::{GroupId, Uuid};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "groups")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub group_id: GroupId,
pub display_name: String,
pub creation_date: chrono::NaiveDateTime,
pub uuid: Uuid,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::memberships::Entity")]
Memberships,
}
impl Related<super::memberships::Entity> for Entity {
fn to() -> RelationDef {
Relation::Memberships.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
impl From<Model> for crate::domain::types::Group {
fn from(group: Model) -> Self {
Self {
id: group.group_id,
display_name: group.display_name,
creation_date: group.creation_date,
uuid: group.uuid,
users: vec![],
}
}
}
impl From<Model> for crate::domain::types::GroupDetails {
fn from(group: Model) -> Self {
Self {
group_id: group.group_id,
display_name: group.display_name,
creation_date: group.creation_date,
uuid: group.uuid,
}
}
}

View File

@ -0,0 +1,35 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::UserId;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "jwt_refresh_storage")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub refresh_token_hash: i64,
pub user_id: UserId,
pub expiry_date: chrono::NaiveDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::UserId",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,36 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::UserId;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "jwt_storage")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub jwt_hash: i64,
pub user_id: UserId,
pub expiry_date: chrono::NaiveDateTime,
pub blacklisted: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::UserId",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,73 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use crate::domain::types::{GroupId, UserId};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "memberships")]
pub struct Model {
#[sea_orm(primary_key)]
pub user_id: UserId,
#[sea_orm(primary_key)]
pub group_id: GroupId,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::groups::Entity",
from = "Column::GroupId",
to = "super::groups::Column::GroupId",
on_update = "Cascade",
on_delete = "Cascade"
)]
Groups,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::UserId",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::groups::Entity> for Entity {
fn to() -> RelationDef {
Relation::Groups.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}
#[derive(Debug)]
pub struct UserToGroup;
impl Linked for UserToGroup {
type FromEntity = super::User;
type ToEntity = super::Group;
fn link(&self) -> Vec<RelationDef> {
vec![Relation::Users.def().rev(), Relation::Groups.def()]
}
}
#[derive(Debug)]
pub struct GroupToUser;
impl Linked for GroupToUser {
type FromEntity = super::Group;
type ToEntity = super::User;
fn link(&self) -> Vec<RelationDef> {
vec![Relation::Groups.def().rev(), Relation::Users.def()]
}
}
impl ActiveModelBehavior for ActiveModel {}

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