Compare commits

...

36 Commits

Author SHA1 Message Date
Elias Schneider
8d55c7c393 release: 0.36.0 2025-03-06 22:25:25 +01:00
Kyle Mendell
0f14a93e1d feat: display groups on the account page (#296)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-06 22:25:03 +01:00
Elias Schneider
37b24bed91 ci/cd: remove PR docker build action 2025-03-06 22:24:00 +01:00
Elias Schneider
66090f36a8 ci/cd: use github.repository variable intead of hardcoding the repository name 2025-03-06 19:13:44 +01:00
Kyle Mendell
ff34e3b925 fix: default sorting on tables (#299)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-06 17:42:31 +01:00
Savely Krasovsky
91f254c7bb feat: enable sd_notify support (#277) 2025-03-06 17:42:12 +01:00
Kyle Mendell
85db96b0ef ci/cd: add pr docker build (#293)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-06 16:29:33 +01:00
Elias Schneider
12d60fea23 release: 0.35.6 2025-03-03 16:49:55 +01:00
Elias Schneider
2d733fc79f fix: support LOGIN authentication method for SMTP (#292) 2025-03-03 16:48:38 +01:00
Elias Schneider
a421d01e0c release: 0.35.5 2025-03-03 16:48:07 +01:00
Elias Schneider
1026ee4f5b fix: profile picture orientation if image is rotated with EXIF 2025-03-03 09:06:52 +01:00
Elias Schneider
cddfe8fa4c release: 0.35.4 2025-03-01 20:42:53 +01:00
Jonas
ef25f6b6b8 fix: profile picture of other user can't be updated (#273) 2025-03-01 20:42:29 +01:00
Elias Schneider
1652cc65f3 fix: support POST for OIDC userinfo endpoint 2025-03-01 20:42:00 +01:00
Elias Schneider
4bafee4f58 fix: add groups scope and claim to well known endpoint 2025-03-01 20:41:30 +01:00
Elias Schneider
e46471cc2d release: 0.35.3 2025-02-25 20:34:37 +01:00
Elias Schneider
fde951b543 fix(ldap): sync error if LDAP user collides with an existing user 2025-02-25 20:34:13 +01:00
Kyle Mendell
01a9de0b04 fix: add option to manually select SMTP TLS method (#268)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-25 19:10:20 +01:00
Elias Schneider
a1131bca9a release: 0.35.2 2025-02-24 09:40:48 +01:00
Elias Schneider
9a167d4076 fix: delete profile picture if user gets deleted 2025-02-24 09:40:14 +01:00
Elias Schneider
887c5e462a fix: updating profile picture of other user updates own profile picture 2025-02-24 09:35:44 +01:00
Elias Schneider
20eba1378e release: 0.35.1 2025-02-22 14:59:43 +01:00
Elias Schneider
a6ae7ae287 fix: add validation that PUBLIC_APP_URL can't contain a path 2025-02-22 14:59:10 +01:00
Elias Schneider
840a672fc3 fix: binary profile picture can't be imported from LDAP 2025-02-22 14:51:21 +01:00
Elias Schneider
7446f853fc release: 0.35.0 2025-02-19 14:29:24 +01:00
Elias Schneider
652ee6ad5d feat: add ability to upload a profile picture (#244) 2025-02-19 14:28:45 +01:00
Elias Schneider
dca9e7a11a fix: emails do not get rendered correctly in Gmail 2025-02-19 13:54:36 +01:00
Elias Schneider
816c198a42 fix: app config strings starting with a number are parsed incorrectly 2025-02-18 21:36:08 +01:00
Elias Schneider
339837bec4 release: 0.34.0 2025-02-16 18:29:18 +01:00
Kyle Mendell
39b46e99a9 feat: add LDAP group membership attribute (#236)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-16 18:27:07 +01:00
Elias Schneider
dc9e64de3d release: 0.33.0 2025-02-14 17:10:14 +01:00
Elias Schneider
6207e10279 Merge branch 'main' of https://github.com/pocket-id/pocket-id 2025-02-14 17:09:39 +01:00
Elias Schneider
7550333fe2 feat: add end session endpoint (#232) 2025-02-14 17:09:27 +01:00
Elias Schneider
3de1301fa8 fix: layout of OIDC client details page on mobile 2025-02-14 16:03:17 +01:00
Elias Schneider
c3980d3d28 fix: alignment of OIDC client details 2025-02-14 15:53:30 +01:00
Elias Schneider
4d0fff821e fix: show "Sync Now" and "Test Email" button even if UI config is disabled 2025-02-14 13:32:01 +01:00
89 changed files with 1795 additions and 637 deletions

View File

@@ -30,11 +30,6 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: 'Login to GitHub Container Registry' - name: 'Login to GitHub Container Registry'
uses: docker/login-action@v3 uses: docker/login-action@v3

View File

@@ -1 +1 @@
0.32.0 0.36.0

View File

@@ -1,3 +1,97 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.6...v) (2025-03-06)
### Features
* display groups on the account page ([#296](https://github.com/pocket-id/pocket-id/issues/296)) ([0f14a93](https://github.com/pocket-id/pocket-id/commit/0f14a93e1d6a723b0994ba475b04702646f04464))
* enable sd_notify support ([#277](https://github.com/pocket-id/pocket-id/issues/277)) ([91f254c](https://github.com/pocket-id/pocket-id/commit/91f254c7bb067646c42424c5c62ebcd90a0c8792))
### Bug Fixes
* default sorting on tables ([#299](https://github.com/pocket-id/pocket-id/issues/299)) ([ff34e3b](https://github.com/pocket-id/pocket-id/commit/ff34e3b925321c80e9d7d42d0fd50e397d198435))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.5...v) (2025-03-03)
### Bug Fixes
* support `LOGIN` authentication method for SMTP ([#292](https://github.com/pocket-id/pocket-id/issues/292)) ([2d733fc](https://github.com/pocket-id/pocket-id/commit/2d733fc79faefca23d54b22768029c3ba3427410))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.4...v) (2025-03-03)
### Bug Fixes
* profile picture orientation if image is rotated with EXIF ([1026ee4](https://github.com/pocket-id/pocket-id/commit/1026ee4f5b5c7fda78b65c94a5d0f899525defd1))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.3...v) (2025-03-01)
### Bug Fixes
* add `groups` scope and claim to well known endpoint ([4bafee4](https://github.com/pocket-id/pocket-id/commit/4bafee4f58f5a76898cf66d6192916d405eea389))
* profile picture of other user can't be updated ([#273](https://github.com/pocket-id/pocket-id/issues/273)) ([ef25f6b](https://github.com/pocket-id/pocket-id/commit/ef25f6b6b84b52f1310d366d40aa3769a6fe9bef))
* support POST for OIDC userinfo endpoint ([1652cc6](https://github.com/pocket-id/pocket-id/commit/1652cc65f3f966d018d81a1ae22abb5ff1b4c47b))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.2...v) (2025-02-25)
### Bug Fixes
* add option to manually select SMTP TLS method ([#268](https://github.com/pocket-id/pocket-id/issues/268)) ([01a9de0](https://github.com/pocket-id/pocket-id/commit/01a9de0b04512c62d0f223de33d711f93c49b9cc))
* **ldap:** sync error if LDAP user collides with an existing user ([fde951b](https://github.com/pocket-id/pocket-id/commit/fde951b543281fedf9f602abae26b50881e3d157))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.1...v) (2025-02-24)
### Bug Fixes
* delete profile picture if user gets deleted ([9a167d4](https://github.com/pocket-id/pocket-id/commit/9a167d4076872e5e3e5d78d2a66ef7203ca5261b))
* updating profile picture of other user updates own profile picture ([887c5e4](https://github.com/pocket-id/pocket-id/commit/887c5e462a50c8fb579ca6804f1a643d8af78fe8))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.0...v) (2025-02-22)
### Bug Fixes
* add validation that `PUBLIC_APP_URL` can't contain a path ([a6ae7ae](https://github.com/pocket-id/pocket-id/commit/a6ae7ae28713f7fc8018ae2aa7572986df3e1a5b))
* binary profile picture can't be imported from LDAP ([840a672](https://github.com/pocket-id/pocket-id/commit/840a672fc35ca8476caf86d7efaba9d54bce86aa))
## [](https://github.com/pocket-id/pocket-id/compare/v0.34.0...v) (2025-02-19)
### Features
* add ability to upload a profile picture ([#244](https://github.com/pocket-id/pocket-id/issues/244)) ([652ee6a](https://github.com/pocket-id/pocket-id/commit/652ee6ad5d6c46f0d35c955ff7bb9bdac6240ca6))
### Bug Fixes
* app config strings starting with a number are parsed incorrectly ([816c198](https://github.com/pocket-id/pocket-id/commit/816c198a42c189cb1f2d94885d2e3623e47e2848))
* emails do not get rendered correctly in Gmail ([dca9e7a](https://github.com/pocket-id/pocket-id/commit/dca9e7a11a3ba5d3b43a937f11cb9d16abad2db5))
## [](https://github.com/pocket-id/pocket-id/compare/v0.33.0...v) (2025-02-16)
### Features
* add LDAP group membership attribute ([#236](https://github.com/pocket-id/pocket-id/issues/236)) ([39b46e9](https://github.com/pocket-id/pocket-id/commit/39b46e99a9b930ea39cf640c3080530cfff5be6e))
## [](https://github.com/pocket-id/pocket-id/compare/v0.32.0...v) (2025-02-14)
### Features
* add end session endpoint ([#232](https://github.com/pocket-id/pocket-id/issues/232)) ([7550333](https://github.com/pocket-id/pocket-id/commit/7550333fe2ff6424f3168f63c5179d76767532fd))
### Bug Fixes
* alignment of OIDC client details ([c3980d3](https://github.com/pocket-id/pocket-id/commit/c3980d3d28a7158a4dc9369af41f185b891e485e))
* layout of OIDC client details page on mobile ([3de1301](https://github.com/pocket-id/pocket-id/commit/3de1301fa84b3ab4fff4242d827c7794d44910f2))
* show "Sync Now" and "Test Email" button even if UI config is disabled ([4d0fff8](https://github.com/pocket-id/pocket-id/commit/4d0fff821e2245050ce631b4465969510466dfae))
## [](https://github.com/pocket-id/pocket-id/compare/v0.31.0...v) (2025-02-13) ## [](https://github.com/pocket-id/pocket-id/compare/v0.31.0...v) (2025-02-13)

View File

@@ -4,6 +4,10 @@ go 1.23.1
require ( require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.7.0 github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.15.0 github.com/go-co-op/gocron/v2 v2.15.0
@@ -17,6 +21,7 @@ require (
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
golang.org/x/crypto v0.32.0 golang.org/x/crypto v0.32.0
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0 golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11 gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.7 gorm.io/driver/sqlite v1.5.7
@@ -28,6 +33,7 @@ require (
github.com/bytedance/sonic v1.12.8 // indirect github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.5 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
@@ -64,9 +70,9 @@ require (
golang.org/x/arch v0.13.0 // indirect golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.10.0 // indirect golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -22,6 +22,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
@@ -30,6 +36,10 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
@@ -211,6 +221,9 @@ golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -235,8 +248,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -268,8 +282,9 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -2,6 +2,7 @@ package bootstrap
import ( import (
"log" "log"
"net"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -10,6 +11,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/job" "github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -41,7 +43,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService) userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
customClaimService := service.NewCustomClaimService(db) customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService) testService := service.NewTestService(db, appConfigService, jwtService)
userGroupService := service.NewUserGroupService(db, appConfigService) userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService) ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
@@ -79,8 +81,20 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
baseGroup := r.Group("/") baseGroup := r.Group("/")
controller.NewWellKnownController(baseGroup, jwtService) controller.NewWellKnownController(baseGroup, jwtService)
// Run the server // Get the listener
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil { l, err := net.Listen("tcp", common.EnvConfig.Host+":"+common.EnvConfig.Port)
if err != nil {
log.Fatal(err)
}
// Notify systemd that we are ready
if err := systemd.SdNotifyReady(); err != nil {
log.Println("Unable to notify systemd that the service is ready: ", err)
// continue to serve anyway since it's not that important
}
// Serve requests
if err := r.RunListener(l); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }

View File

@@ -2,6 +2,7 @@ package common
import ( import (
"log" "log"
"net/url"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
@@ -61,4 +62,12 @@ func init() {
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" { if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable") log.Fatal("Missing SQLITE_DB_PATH environment variable")
} }
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
if err != nil {
log.Fatal("PUBLIC_APP_URL is not a valid URL")
}
if parsedAppUrl.Path != "" {
log.Fatal("PUBLIC_APP_URL must not contain a path")
}
} }

View File

@@ -31,6 +31,13 @@ type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" } func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 } func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string {
return "Token is invalid"
}
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
type OidcMissingAuthorizationError struct{} type OidcMissingAuthorizationError struct{}
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" } func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
@@ -87,6 +94,11 @@ type NotSignedInError struct{}
func (e *NotSignedInError) Error() string { return "You are not signed in" } func (e *NotSignedInError) Error() string { return "You are not signed in" }
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized } func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingAccessToken struct{}
func (e *MissingAccessToken) Error() string { return "Missing access token" }
func (e *MissingAccessToken) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{} type MissingPermissionError struct{}
func (e *MissingPermissionError) Error() string { func (e *MissingPermissionError) Error() string {
@@ -182,12 +194,33 @@ type OidcAccessDeniedError struct{}
func (e *OidcAccessDeniedError) Error() string { func (e *OidcAccessDeniedError) Error() string {
return "You're not allowed to access this service" return "You're not allowed to access this service"
} }
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden } func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcClientIdNotMatchingError struct{}
func (e *OidcClientIdNotMatchingError) Error() string {
return "Client id in request doesn't match client id in token"
}
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcNoCallbackURLError struct{}
func (e *OidcNoCallbackURLError) Error() string {
return "No callback URL provided"
}
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type UiConfigDisabledError struct{} type UiConfigDisabledError struct{}
func (e *UiConfigDisabledError) Error() string { func (e *UiConfigDisabledError) Error() string {
return "The configuration can't be changed since the UI configuration is disabled" return "The configuration can't be changed since the UI configuration is disabled"
} }
func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden } func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type InvalidUUIDError struct{}
func (e *InvalidUUIDError) Error() string {
return "Invalid UUID"
}
type InvalidEmailError struct{}

View File

@@ -1,7 +1,11 @@
package controller package controller
import ( import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"log"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -19,6 +23,9 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/oidc/token", oc.createTokensHandler) group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler) group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", oc.EndSessionHandler)
group.GET("/oidc/end-session", oc.EndSessionHandler)
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler) group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler) group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
@@ -105,7 +112,14 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
} }
func (oc *OidcController) userInfoHandler(c *gin.Context) { func (oc *OidcController) userInfoHandler(c *gin.Context) {
token := strings.Split(c.GetHeader("Authorization"), " ")[1] authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
if len(authHeaderSplit) != 2 {
c.Error(&common.MissingAccessToken{})
return
}
token := authHeaderSplit[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token) jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
@@ -122,6 +136,44 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
c.JSON(http.StatusOK, claims) c.JSON(http.StatusOK, claims)
} }
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
var input dto.OidcLogoutDto
// Bind query parameters to the struct
if c.Request.Method == http.MethodGet {
if err := c.ShouldBindQuery(&input); err != nil {
c.Error(err)
return
}
} else if c.Request.Method == http.MethodPost {
// Bind form parameters to the struct
if err := c.ShouldBind(&input); err != nil {
c.Error(err)
return
}
}
callbackURL, err := oc.oidcService.ValidateEndSession(input, c.GetString("userID"))
if err != nil {
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return
}
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
cookie.AddAccessTokenCookie(c, 0, "")
logoutCallbackURL, _ := url.Parse(callbackURL)
if input.State != "" {
q := logoutCallbackURL.Query()
q.Set("state", input.State)
logoutCallbackURL.RawQuery = q.Encode()
}
c.Redirect(http.StatusFound, logoutCallbackURL.String())
}
func (oc *OidcController) getClientHandler(c *gin.Context) { func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id") clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId) client, err := oc.oidcService.GetClient(clientId)

View File

@@ -38,5 +38,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return return
} }
tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }

View File

@@ -27,9 +27,17 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler) group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler) group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler) group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler)
group.GET("/users/:id/groups", jwtAuthMiddleware.Add(true), uc.getUserGroupsHandler)
group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler) group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler)
group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler) group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler)
group.PUT("/users/:id/user-groups", jwtAuthMiddleware.Add(true), uc.updateUserGroups)
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
group.GET("/users/me/profile-picture.png", jwtAuthMiddleware.Add(false), uc.getCurrentUserProfilePictureHandler)
group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler)
group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateCurrentUserProfilePictureHandler)
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler) group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler) group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
@@ -41,6 +49,23 @@ type UserController struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
} }
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id")
groups, err := uc.userService.GetUserGroups(userID)
if err != nil {
c.Error(err)
return
}
var groupsDto []dto.UserGroupDtoWithUsers
if err := dto.MapStructList(groups, &groupsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, groupsDto)
}
func (uc *UserController) listUsersHandler(c *gin.Context) { func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest var sortedPaginationRequest utils.SortedPaginationRequest
@@ -142,6 +167,74 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
uc.updateUser(c, true) uc.updateUser(c, true)
} }
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
picture, size, err := uc.userService.GetProfilePicture(userID)
if err != nil {
c.Error(err)
return
}
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
picture, size, err := uc.userService.GetProfilePicture(userID)
if err != nil {
c.Error(err)
return
}
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
fileHeader, err := c.FormFile("file")
if err != nil {
c.Error(err)
return
}
file, err := fileHeader.Open()
if err != nil {
c.Error(err)
return
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
fileHeader, err := c.FormFile("file")
if err != nil {
c.Error(err)
return
}
file, err := fileHeader.Open()
if err != nil {
c.Error(err)
return
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
var input dto.OneTimeAccessTokenCreateDto var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
@@ -242,3 +335,25 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
} }
func (uc *UserController) updateUserGroups(c *gin.Context) {
var input dto.UserUpdateUserGroupDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}

View File

@@ -139,7 +139,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
return return
} }
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input) group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input.UserIDs)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -35,9 +35,10 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
"authorization_endpoint": appUrl + "/authorize", "authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token", "token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo", "userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"jwks_uri": appUrl + "/.well-known/jwks.json", "jwks_uri": appUrl + "/.well-known/jwks.json",
"scopes_supported": []string{"openid", "profile", "email"}, "scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"}, "response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"}, "subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"}, "id_token_signing_alg_values_supported": []string{"RS256"},

View File

@@ -21,7 +21,7 @@ type AppConfigUpdateDto struct {
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"` SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"` SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls"` SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
LdapEnabled string `json:"ldapEnabled" binding:"required"` LdapEnabled string `json:"ldapEnabled" binding:"required"`
LdapUrl string `json:"ldapUrl"` LdapUrl string `json:"ldapUrl"`
@@ -36,6 +36,8 @@ type AppConfigUpdateDto struct {
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"` LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"` LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`

View File

@@ -8,24 +8,27 @@ type PublicOidcClientDto struct {
type OidcClientDto struct { type OidcClientDto struct {
PublicOidcClientDto PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
PkceEnabled bool `json:"pkceEnabled"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
} }
type OidcClientWithAllowedUserGroupsDto struct { type OidcClientWithAllowedUserGroupsDto struct {
PublicOidcClientDto PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
PkceEnabled bool `json:"pkceEnabled"` IsPublic bool `json:"isPublic"`
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"` PkceEnabled bool `json:"pkceEnabled"`
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
} }
type OidcClientCreateDto struct { type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"` Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"` CallbackURLs []string `json:"callbackURLs" binding:"required"`
IsPublic bool `json:"isPublic"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
PkceEnabled bool `json:"pkceEnabled"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
} }
type AuthorizeOidcClientRequestDto struct { type AuthorizeOidcClientRequestDto struct {
@@ -58,3 +61,10 @@ type OidcCreateTokensDto struct {
type OidcUpdateAllowedUserGroupsDto struct { type OidcUpdateAllowedUserGroupsDto struct {
UserGroupIDs []string `json:"userGroupIds" binding:"required"` UserGroupIDs []string `json:"userGroupIds" binding:"required"`
} }
type OidcLogoutDto struct {
IdTokenHint string `form:"id_token_hint"`
ClientId string `form:"client_id"`
PostLogoutRedirectUri string `form:"post_logout_redirect_uri"`
State string `form:"state"`
}

View File

@@ -10,6 +10,7 @@ type UserDto struct {
LastName string `json:"lastName"` LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupDto `json:"userGroups"`
LdapID *string `json:"ldapId"` LdapID *string `json:"ldapId"`
} }
@@ -31,3 +32,7 @@ type OneTimeAccessEmailDto struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
RedirectPath string `json:"redirectPath"` RedirectPath string `json:"redirectPath"`
} }
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"`
}

View File

@@ -4,6 +4,15 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
) )
type UserGroupDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupDtoWithUsers struct { type UserGroupDtoWithUsers struct {
ID string `json:"id"` ID string `json:"id"`
FriendlyName string `json:"friendlyName"` FriendlyName string `json:"friendlyName"`

View File

@@ -43,6 +43,8 @@ type AppConfig struct {
LdapAttributeUserEmail AppConfigVariable LdapAttributeUserEmail AppConfigVariable
LdapAttributeUserFirstName AppConfigVariable LdapAttributeUserFirstName AppConfigVariable
LdapAttributeUserLastName AppConfigVariable LdapAttributeUserLastName AppConfigVariable
LdapAttributeUserProfilePicture AppConfigVariable
LdapAttributeGroupMember AppConfigVariable
LdapAttributeGroupUniqueIdentifier AppConfigVariable LdapAttributeGroupUniqueIdentifier AppConfigVariable
LdapAttributeGroupName AppConfigVariable LdapAttributeGroupName AppConfigVariable
LdapAttributeAdminGroup AppConfigVariable LdapAttributeAdminGroup AppConfigVariable

View File

@@ -37,13 +37,14 @@ type OidcAuthorizationCode struct {
type OidcClient struct { type OidcClient struct {
Base Base
Name string `sortable:"true"` Name string `sortable:"true"`
Secret string Secret string
CallbackURLs CallbackURLs CallbackURLs UrlList
ImageType *string LogoutCallbackURLs UrlList
HasLogo bool `gorm:"-"` ImageType *string
IsPublic bool HasLogo bool `gorm:"-"`
PkceEnabled bool IsPublic bool
PkceEnabled bool
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string CreatedByID string
@@ -56,9 +57,9 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil return nil
} }
type CallbackURLs []string type UrlList []string
func (cu *CallbackURLs) Scan(value interface{}) error { func (cu *UrlList) Scan(value interface{}) error {
if v, ok := value.([]byte); ok { if v, ok := value.([]byte); ok {
return json.Unmarshal(v, cu) return json.Unmarshal(v, cu)
} else { } else {
@@ -66,6 +67,6 @@ func (cu *CallbackURLs) Scan(value interface{}) error {
} }
} }
func (cu CallbackURLs) Value() (driver.Value, error) { func (cu UrlList) Value() (driver.Value, error) {
return json.Marshal(cu) return json.Marshal(cu)
} }

View File

@@ -27,6 +27,7 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
if err := service.InitDbConfig(); err != nil { if err := service.InitDbConfig(); err != nil {
log.Fatalf("Failed to initialize app config service: %v", err) log.Fatalf("Failed to initialize app config service: %v", err)
} }
return service return service
} }
@@ -96,8 +97,8 @@ var defaultDbConfig = model.AppConfig{
}, },
SmtpTls: model.AppConfigVariable{ SmtpTls: model.AppConfigVariable{
Key: "smtpTls", Key: "smtpTls",
Type: "bool", Type: "string",
DefaultValue: "true", DefaultValue: "none",
}, },
SmtpSkipCertVerify: model.AppConfigVariable{ SmtpSkipCertVerify: model.AppConfigVariable{
Key: "smtpSkipCertVerify", Key: "smtpSkipCertVerify",
@@ -173,6 +174,15 @@ var defaultDbConfig = model.AppConfig{
Key: "ldapAttributeUserLastName", Key: "ldapAttributeUserLastName",
Type: "string", Type: "string",
}, },
LdapAttributeUserProfilePicture: model.AppConfigVariable{
Key: "ldapAttributeUserProfilePicture",
Type: "string",
},
LdapAttributeGroupMember: model.AppConfigVariable{
Key: "ldapAttributeGroupMember",
Type: "string",
DefaultValue: "member",
},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{ LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeGroupUniqueIdentifier", Key: "ldapAttributeGroupUniqueIdentifier",
Type: "string", Type: "string",

View File

@@ -3,27 +3,23 @@ package service
import ( import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
htemplate "html/template" "github.com/emersion/go-sasl"
"mime/multipart" "github.com/emersion/go-smtp"
"mime/quotedprintable"
"net"
"net/smtp"
"net/textproto"
"os"
ttemplate "text/template"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils/email" "github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm" "gorm.io/gorm"
htemplate "html/template"
"mime/multipart"
"mime/quotedprintable"
"net/textproto"
"os"
ttemplate "text/template"
"time"
) )
var netDialer = &net.Dialer{
Timeout: 3 * time.Second,
}
type EmailService struct { type EmailService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
db *gorm.DB db *gorm.DB
@@ -114,105 +110,57 @@ func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value, ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
} }
// Connect to the SMTP server // Connect to the SMTP server based on TLS setting
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" { switch srv.appConfigService.DbConfig.SmtpTls.Value {
client, err = srv.connectToSmtpServer(smtpAddress) case "none":
} else if port == "465" { client, err = smtp.Dial(smtpAddress)
client, err = srv.connectToSmtpServerUsingImplicitTLS( case "tls":
smtpAddress, client, err = smtp.DialTLS(smtpAddress, tlsConfig)
tlsConfig, case "starttls":
) client, err = smtp.DialStartTLS(
} else {
client, err = srv.connectToSmtpServerUsingStartTLS(
smtpAddress, smtpAddress,
tlsConfig, tlsConfig,
) )
default:
return nil, fmt.Errorf("invalid SMTP TLS setting: %s", srv.appConfigService.DbConfig.SmtpTls.Value)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err) return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
} }
client.CommandTimeout = 10 * time.Second
// Send the HELO command
if err := srv.sendHelloCommand(client); err != nil {
return nil, fmt.Errorf("failed to send HELO command: %w", err)
}
// Set up the authentication if user or password are set // Set up the authentication if user or password are set
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
if smtpUser != "" || smtpPassword != "" { if smtpUser != "" || smtpPassword != "" {
auth := smtp.PlainAuth("", // Authenticate with plain auth
srv.appConfigService.DbConfig.SmtpUser.Value, auth := sasl.NewPlainClient("", smtpUser, smtpPassword)
srv.appConfigService.DbConfig.SmtpPassword.Value,
srv.appConfigService.DbConfig.SmtpHost.Value,
)
if err := client.Auth(auth); err != nil { if err := client.Auth(auth); err != nil {
return nil, fmt.Errorf("failed to authenticate SMTP client: %w", err) // If the server does not support plain auth, try login auth
var smtpErr *smtp.SMTPError
ok := errors.As(err, &smtpErr)
if ok && smtpErr.Code == smtp.ErrAuthUnknownMechanism.Code {
auth = sasl.NewLoginClient(smtpUser, smtpPassword)
err = client.Auth(auth)
}
// Both plain and login auth failed
if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
} }
} }
return client, err return client, err
} }
func (srv *EmailService) connectToSmtpServer(serverAddr string) (*smtp.Client, error) {
conn, err := netDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := srv.sendHelloCommand(client); err != nil {
return nil, fmt.Errorf("failed to say hello to SMTP server: %w", err)
}
return client, err
}
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
tlsDialer := &tls.Dialer{
NetDialer: netDialer,
Config: tlsConfig,
}
conn, err := tlsDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := srv.sendHelloCommand(client); err != nil {
return nil, fmt.Errorf("failed to say hello to SMTP server: %w", err)
}
return client, nil
}
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := netDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := srv.sendHelloCommand(client); err != nil {
return nil, fmt.Errorf("failed to say hello to SMTP server: %w", err)
}
if err := client.StartTLS(tlsConfig); err != nil {
return nil, fmt.Errorf("failed to start TLS: %w", err)
}
return client, nil
}
func (srv *EmailService) sendHelloCommand(client *smtp.Client) error { func (srv *EmailService) sendHelloCommand(client *smtp.Client) error {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err == nil { if err == nil {
@@ -224,23 +172,33 @@ func (srv *EmailService) sendHelloCommand(client *smtp.Client) error {
} }
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error { func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil { // Set the sender
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value, nil); err != nil {
return fmt.Errorf("failed to set sender: %w", err) return fmt.Errorf("failed to set sender: %w", err)
} }
if err := client.Rcpt(toEmail.Email); err != nil {
// Set the recipient
if err := client.Rcpt(toEmail.Email, nil); err != nil {
return fmt.Errorf("failed to set recipient: %w", err) return fmt.Errorf("failed to set recipient: %w", err)
} }
// Get a writer to write the email data
w, err := client.Data() w, err := client.Data()
if err != nil { if err != nil {
return fmt.Errorf("failed to start data: %w", err) return fmt.Errorf("failed to start data: %w", err)
} }
// Write the email content
_, err = w.Write([]byte(c.String())) _, err = w.Write([]byte(c.String()))
if err != nil { if err != nil {
return fmt.Errorf("failed to write email data: %w", err) return fmt.Errorf("failed to write email data: %w", err)
} }
// Close the writer
if err := w.Close(); err != nil { if err := w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err) return fmt.Errorf("failed to close data writer: %w", err)
} }
return nil return nil
} }

View File

@@ -8,7 +8,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt"
"log" "log"
"math/big" "math/big"
"os" "os"
@@ -28,8 +27,8 @@ const (
) )
type JwtService struct { type JwtService struct {
publicKey *rsa.PublicKey PublicKey *rsa.PublicKey
privateKey *rsa.PrivateKey PrivateKey *rsa.PrivateKey
appConfigService *AppConfigService appConfigService *AppConfigService
} }
@@ -72,7 +71,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
if err != nil { if err != nil {
return errors.New("can't read jwt private key: " + err.Error()) return errors.New("can't read jwt private key: " + err.Error())
} }
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes) s.PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
if err != nil { if err != nil {
return errors.New("can't parse jwt private key: " + err.Error()) return errors.New("can't parse jwt private key: " + err.Error())
} }
@@ -81,7 +80,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
if err != nil { if err != nil {
return errors.New("can't read jwt public key: " + err.Error()) return errors.New("can't read jwt public key: " + err.Error())
} }
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes) s.PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
if err != nil { if err != nil {
return errors.New("can't parse jwt public key: " + err.Error()) return errors.New("can't parse jwt public key: " + err.Error())
} }
@@ -101,7 +100,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
IsAdmin: user.IsAdmin, IsAdmin: user.IsAdmin,
} }
kid, err := s.generateKeyID(s.publicKey) kid, err := s.generateKeyID(s.PublicKey)
if err != nil { if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error()) return "", errors.New("failed to generate key ID: " + err.Error())
} }
@@ -109,12 +108,12 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim) token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid token.Header["kid"] = kid
return token.SignedString(s.privateKey) return token.SignedString(s.PrivateKey)
} }
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) { func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.publicKey, nil return s.PublicKey, nil
}) })
if err != nil || !token.Valid { if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token") return nil, errors.New("couldn't handle this token")
@@ -147,7 +146,7 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
claims["nonce"] = nonce claims["nonce"] = nonce
} }
kid, err := s.generateKeyID(s.publicKey) kid, err := s.generateKeyID(s.PublicKey)
if err != nil { if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error()) return "", errors.New("failed to generate key ID: " + err.Error())
} }
@@ -155,7 +154,7 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = kid token.Header["kid"] = kid
return token.SignedString(s.privateKey) return token.SignedString(s.PrivateKey)
} }
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) { func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
@@ -167,7 +166,7 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
Issuer: common.EnvConfig.AppURL, Issuer: common.EnvConfig.AppURL,
} }
kid, err := s.generateKeyID(s.publicKey) kid, err := s.generateKeyID(s.PublicKey)
if err != nil { if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error()) return "", errors.New("failed to generate key ID: " + err.Error())
} }
@@ -175,12 +174,12 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim) token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid token.Header["kid"] = kid
return token.SignedString(s.privateKey) return token.SignedString(s.PrivateKey)
} }
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) { func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.publicKey, nil return s.PublicKey, nil
}) })
if err != nil || !token.Valid { if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token") return nil, errors.New("couldn't handle this token")
@@ -194,13 +193,30 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.Registered
return claims, nil return claims, nil
} }
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.PublicKey, nil
}, jwt.WithIssuer(common.EnvConfig.AppURL))
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New("couldn't handle this token")
}
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
return claims, nil
}
// GetJWK returns the JSON Web Key (JWK) for the public key. // GetJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetJWK() (JWK, error) { func (s *JwtService) GetJWK() (JWK, error) {
if s.publicKey == nil { if s.PublicKey == nil {
return JWK{}, errors.New("public key is not initialized") return JWK{}, errors.New("public key is not initialized")
} }
kid, err := s.generateKeyID(s.publicKey) kid, err := s.generateKeyID(s.PublicKey)
if err != nil { if err != nil {
return JWK{}, err return JWK{}, err
} }
@@ -210,8 +226,8 @@ func (s *JwtService) GetJWK() (JWK, error) {
Kty: "RSA", Kty: "RSA",
Use: "sig", Use: "sig",
Alg: "RS256", Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(s.publicKey.N.Bytes()), N: base64.RawURLEncoding.EncodeToString(s.PublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.publicKey.E)).Bytes()), E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.PublicKey.E)).Bytes()),
} }
return jwk, nil return jwk, nil
@@ -246,14 +262,14 @@ func (s *JwtService) generateKeys() error {
if err != nil { if err != nil {
return errors.New("failed to generate private key: " + err.Error()) return errors.New("failed to generate private key: " + err.Error())
} }
s.privateKey = privateKey s.PrivateKey = privateKey
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil { if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
return err return err
} }
publicKey := &privateKey.PublicKey publicKey := &privateKey.PublicKey
s.publicKey = publicKey s.PublicKey = publicKey
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil { if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
return err return err
@@ -281,32 +297,3 @@ func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) er
return nil return nil
} }
// loadKeys loads RSA keys from the given paths.
func (s *JwtService) loadKeys() error {
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
if err := s.generateKeys(); err != nil {
return err
}
}
privateKeyBytes, err := os.ReadFile(privateKeyPath)
if err != nil {
return fmt.Errorf("can't read jwt private key: %w", err)
}
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
if err != nil {
return fmt.Errorf("can't parse jwt private key: %w", err)
}
publicKeyBytes, err := os.ReadFile(publicKeyPath)
if err != nil {
return fmt.Errorf("can't read jwt public key: %w", err)
}
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
if err != nil {
return fmt.Errorf("can't parse jwt public key: %w", err)
}
return nil
}

View File

@@ -1,9 +1,15 @@
package service package service
import ( import (
"bytes"
"crypto/tls" "crypto/tls"
"encoding/base64"
"errors"
"fmt" "fmt"
"io"
"log" "log"
"net/http"
"net/url"
"strings" "strings"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
@@ -70,12 +76,13 @@ func (s *LdapService) SyncGroups() error {
baseDN := s.appConfigService.DbConfig.LdapBase.Value baseDN := s.appConfigService.DbConfig.LdapBase.Value
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
groupMemberOfAttribute := s.appConfigService.DbConfig.LdapAttributeGroupMember.Value
filter := s.appConfigService.DbConfig.LdapUserGroupSearchFilter.Value filter := s.appConfigService.DbConfig.LdapUserGroupSearchFilter.Value
searchAttrs := []string{ searchAttrs := []string{
nameAttribute, nameAttribute,
uniqueIdentifierAttribute, uniqueIdentifierAttribute,
"member", groupMemberOfAttribute,
} }
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{}) searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
@@ -88,7 +95,6 @@ func (s *LdapService) SyncGroups() error {
ldapGroupIDs := make(map[string]bool) ldapGroupIDs := make(map[string]bool)
for _, value := range result.Entries { for _, value := range result.Entries {
var usersToAddDto dto.UserGroupUpdateUsersDto
var membersUserId []string var membersUserId []string
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute) ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
@@ -99,14 +105,23 @@ func (s *LdapService) SyncGroups() error {
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup) s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
// Get group members and add to the correct Group // Get group members and add to the correct Group
groupMembers := value.GetAttributeValues("member") groupMembers := value.GetAttributeValues(groupMemberOfAttribute)
for _, member := range groupMembers { for _, member := range groupMembers {
// Normal output of this would be CN=username,ou=people,dc=example,dc=com // Normal output of this would be CN=username,ou=people,dc=example,dc=com
// Splitting at the "=" and "," then just grabbing the username for that string // Splitting at the "=" and "," then just grabbing the username for that string
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0] singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
var databaseUser model.User var databaseUser model.User
s.db.Where("username = ?", singleMember).Where("ldap_id IS NOT NULL").First(&databaseUser) err := s.db.Where("username = ? AND ldap_id IS NOT NULL", singleMember).First(&databaseUser).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// The user collides with a non-LDAP user, so we skip it
continue
} else {
return err
}
}
membersUserId = append(membersUserId, databaseUser.ID) membersUserId = append(membersUserId, databaseUser.ID)
} }
@@ -117,22 +132,18 @@ func (s *LdapService) SyncGroups() error {
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute), LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
} }
usersToAddDto = dto.UserGroupUpdateUsersDto{
UserIDs: membersUserId,
}
if databaseGroup.ID == "" { if databaseGroup.ID == "" {
newGroup, err := s.groupService.Create(syncGroup) newGroup, err := s.groupService.Create(syncGroup)
if err != nil { if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
} else { } else {
if _, err = s.groupService.UpdateUsers(newGroup.ID, usersToAddDto); err != nil { if _, err = s.groupService.UpdateUsers(newGroup.ID, membersUserId); err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
} }
} }
} else { } else {
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true) _, err = s.groupService.Update(databaseGroup.ID, syncGroup, true)
_, err = s.groupService.UpdateUsers(databaseGroup.ID, usersToAddDto) _, err = s.groupService.UpdateUsers(databaseGroup.ID, membersUserId)
if err != nil { if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
return err return err
@@ -176,6 +187,7 @@ func (s *LdapService) SyncUsers() error {
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
profilePictureAttribute := s.appConfigService.DbConfig.LdapAttributeUserProfilePicture.Value
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value
@@ -188,6 +200,7 @@ func (s *LdapService) SyncUsers() error {
emailAttribute, emailAttribute,
firstNameAttribute, firstNameAttribute,
lastNameAttribute, lastNameAttribute,
profilePictureAttribute,
} }
// Filters must start and finish with ()! // Filters must start and finish with ()!
@@ -236,9 +249,14 @@ func (s *LdapService) SyncUsers() error {
if err != nil { if err != nil {
log.Printf("Error syncing user %s: %s", newUser.Username, err) log.Printf("Error syncing user %s: %s", newUser.Username, err)
} }
} }
// Save profile picture
if pictureString := value.GetAttributeValue(profilePictureAttribute); pictureString != "" {
if err := s.SaveProfilePicture(databaseUser.ID, pictureString); err != nil {
log.Printf("Error saving profile picture for user %s: %s", newUser.Username, err)
}
}
} }
// Get all LDAP users from the database // Get all LDAP users from the database
@@ -250,7 +268,7 @@ func (s *LdapService) SyncUsers() error {
// Delete users that no longer exist in LDAP // Delete users that no longer exist in LDAP
for _, user := range ldapUsersInDb { for _, user := range ldapUsersInDb {
if _, exists := ldapUserIDs[*user.LdapID]; !exists { if _, exists := ldapUserIDs[*user.LdapID]; !exists {
if err := s.db.Delete(&model.User{}, "ldap_id = ?", user.LdapID).Error; err != nil { if err := s.userService.DeleteUser(user.ID); err != nil {
log.Printf("Failed to delete user %s with: %v", user.Username, err) log.Printf("Failed to delete user %s with: %v", user.Username, err)
} else { } else {
log.Printf("Deleted user %s", user.Username) log.Printf("Deleted user %s", user.Username)
@@ -259,3 +277,33 @@ func (s *LdapService) SyncUsers() error {
} }
return nil return nil
} }
func (s *LdapService) SaveProfilePicture(userId string, pictureString string) error {
var reader io.Reader
if _, err := url.ParseRequestURI(pictureString); err == nil {
// If the photo is a URL, download it
response, err := http.Get(pictureString)
if err != nil {
return fmt.Errorf("failed to download profile picture: %w", err)
}
defer response.Body.Close()
reader = response.Body
} else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil {
// If the photo is a base64 encoded string, decode it
reader = bytes.NewReader(decodedPhoto)
} else {
// If the photo is a string, we assume that it's a binary string
reader = bytes.NewReader([]byte(pictureString))
}
// Update the profile picture
if err := s.userService.UpdateProfilePicture(userId, reader); err != nil {
return fmt.Errorf("failed to update profile picture: %w", err)
}
return nil
}

View File

@@ -51,7 +51,7 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
} }
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed // Get the callback URL of the client. Return an error if the provided callback URL is not allowed
callbackURL, err := s.getCallbackURL(client, input.CallbackURL) callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -228,11 +228,12 @@ func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest uti
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) { func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{ client := model.OidcClient{
Name: input.Name, Name: input.Name,
CallbackURLs: input.CallbackURLs, CallbackURLs: input.CallbackURLs,
CreatedByID: userID, LogoutCallbackURLs: input.LogoutCallbackURLs,
IsPublic: input.IsPublic, CreatedByID: userID,
PkceEnabled: input.IsPublic || input.PkceEnabled, IsPublic: input.IsPublic,
PkceEnabled: input.IsPublic || input.PkceEnabled,
} }
if err := s.db.Create(&client).Error; err != nil { if err := s.db.Create(&client).Error; err != nil {
@@ -250,6 +251,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
client.Name = input.Name client.Name = input.Name
client.CallbackURLs = input.CallbackURLs client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled client.PkceEnabled = input.IsPublic || input.PkceEnabled
@@ -399,6 +401,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
"family_name": user.LastName, "family_name": user.LastName,
"name": user.FullName(), "name": user.FullName(),
"preferred_username": user.Username, "preferred_username": user.Username,
"picture": fmt.Sprintf("%s/api/users/%s/profile-picture.png", common.EnvConfig.AppURL, user.ID),
} }
if strings.Contains(scope, "profile") { if strings.Contains(scope, "profile") {
@@ -460,6 +463,46 @@ func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAll
return client, nil return client, nil
} }
// ValidateEndSession returns the logout callback URL for the client if all the validations pass
func (s *OidcService) ValidateEndSession(input dto.OidcLogoutDto, userID string) (string, error) {
// If no ID token hint is provided, return an error
if input.IdTokenHint == "" {
return "", &common.TokenInvalidError{}
}
// If the ID token hint is provided, verify the ID token
claims, err := s.jwtService.VerifyIdToken(input.IdTokenHint)
if err != nil {
return "", &common.TokenInvalidError{}
}
// If the client ID is provided check if the client ID in the ID token matches the client ID in the request
if input.ClientId != "" && claims.Audience[0] != input.ClientId {
return "", &common.OidcClientIdNotMatchingError{}
}
clientId := claims.Audience[0]
// Check if the user has authorized the client before
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientId, userID).Error; err != nil {
return "", &common.OidcMissingAuthorizationError{}
}
// If the client has no logout callback URLs, return an error
if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) == 0 {
return "", &common.OidcNoCallbackURLError{}
}
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client.LogoutCallbackURLs, input.PostLogoutRedirectUri)
if err != nil {
return "", err
}
return callbackURL, nil
}
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) { func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(32) randomString, err := utils.GenerateRandomAlphanumericString(32)
if err != nil { if err != nil {
@@ -506,12 +549,12 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
return encodedVerifierHash == codeChallenge return encodedVerifierHash == codeChallenge
} }
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) { func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (callbackURL string, err error) {
if inputCallbackURL == "" { if inputCallbackURL == "" {
return client.CallbackURLs[0], nil return urls[0], nil
} }
for _, callbackPattern := range client.CallbackURLs { for _, callbackPattern := range urls {
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$" regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL) matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
if err != nil { if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/pem"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -23,11 +24,12 @@ import (
type TestService struct { type TestService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService appConfigService *AppConfigService
} }
func NewTestService(db *gorm.DB, appConfigService *AppConfigService) *TestService { func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService {
return &TestService{db: db, appConfigService: appConfigService} return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
} }
func (s *TestService) SeedDatabase() error { func (s *TestService) SeedDatabase() error {
@@ -112,11 +114,12 @@ func (s *TestService) SeedDatabase() error {
Base: model.Base{ Base: model.Base{
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055", ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
}, },
Name: "Nextcloud", Name: "Nextcloud",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"}, CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
ImageType: utils.StringPointer("png"), LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
CreatedByID: users[0].ID, ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
}, },
{ {
Base: model.Base{ Base: model.Base{
@@ -124,7 +127,7 @@ func (s *TestService) SeedDatabase() error {
}, },
Name: "Immich", Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"}, CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: users[1].ID, CreatedByID: users[1].ID,
AllowedUserGroups: []model.UserGroup{ AllowedUserGroups: []model.UserGroup{
userGroups[1], userGroups[1],
@@ -288,6 +291,43 @@ func (s *TestService) ResetAppConfig() error {
return s.appConfigService.LoadDbConfigFromDb() return s.appConfigService.LoadDbConfigFromDb()
} }
func (s *TestService) SetJWTKeys() {
privateKeyString := `-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAyaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B
83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC+585UXacoJ0c
hUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl/4EDDTO8HwawTjwkPo
QlRzeByhlvGPVvwgB3Fn93B8QJ/cZhXKxJvjjrC/8Pk76heC/ntEMru71Ix77BoC
3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeO
Zl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJwIDAQABAoIBAQCa8wNZJ08+9y6b
RzSIQcTaBuq1XY0oyYvCuX0ToruDyVNX3lJ48udb9vDIw9XsQans9CTeXXsjldGE
WPN7sapOcUg6ArMyJqc+zuO/YQu0EwYrTE48BOC7WIZvvTFnq9y+4R9HJjd0nTOv
iOlR1W5fAqbH2srgh1mfZ0UIp+9K6ymoinPXVGEXUAuuoMuTEZW/tnA2HT9WEllT
2FyMbmXrFzutAQqk9GRmnQh2OQZLxnQWyShVqJEhYBtm6JUUH1YJbyTVzMLgdBM8
ukgjTVtRDHaW51ubRSVdGBVT2m1RRtTsYAiZCpM5bwt88aSUS9yDOUiVH+irDg/3
IHEuL7IxAoGBAP2MpXPXtOwinajUQ9hKLDAtpq4axGvY+aGP5dNEMsuPo5ggOfUP
b4sqr73kaNFO3EbxQOQVoFjehhi4dQxt1/kAala9HZ5N7s26G2+eUWFF8jy7gWSN
qusNqGrG4g8D3WOyqZFb/x/m6SE0Jcg7zvIYbnAOq1Fexeik0Fc/DNzLAoGBAMua
d4XIfu4ydtU5AIaf1ZNXywgLg+LWxK8ELNqH/Y2vLAeIiTrOVp+hw9z+zHPD5cnu
6mix783PCOYNLTylrwtAz3fxSz14lsDFQM3ntzVF/6BniTTkKddctcPyqnTvamah
0hD2dzXBS/0mTBYIIMYTNbs0Yj87FTdJZw/+qa2VAoGBAKbzQkp54W6PCIMPabD0
fg4nMRZ5F5bv4seIKcunn068QPs9VQxQ4qCfNeLykDYqGA86cgD9YHzD4UZLxv6t
IUWbCWod0m/XXwPlpIUlmO5VEUD+MiAUzFNDxf6xAE7ku5UXImJNUjseX6l2Xd5v
yz9L6QQuFI5aujQKugiIwp5rAoGATtUVGCCkPNgfOLmkYXu7dxxUCV5kB01+xAEK
2OY0n0pG8vfDophH4/D/ZC7nvJ8J9uDhs/3JStexq1lIvaWtG99RNTChIEDzpdn6
GH9yaVcb/eB4uJjrNm64FhF8PGCCwxA+xMCZMaARKwhMB2/IOMkxUbWboL3gnhJ2
rDO/QO0CgYEA2Grt6uXHm61ji3xSdkBWNtUnj19vS1+7rFJp5SoYztVQVThf/W52
BAiXKBdYZDRVoItC/VS2NvAOjeJjhYO/xQ/q3hK7MdtuXfEPpLnyXKkmWo3lrJ26
wbeF6l05LexCkI7ShsOuSt+dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI=
-----END RSA PRIVATE KEY-----
`
block, _ := pem.Decode([]byte(privateKeyString))
privateKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
s.jwtService.PrivateKey = privateKey
s.jwtService.PublicKey = &privateKey.PublicKey
}
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key // getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) { func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey) decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)

View File

@@ -103,16 +103,16 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allow
return group, nil return group, nil
} }
func (s *UserGroupService) UpdateUsers(id string, input dto.UserGroupUpdateUsersDto) (group model.UserGroup, err error) { func (s *UserGroupService) UpdateUsers(id string, userIds []string) (group model.UserGroup, err error) {
group, err = s.Get(id) group, err = s.Get(id)
if err != nil { if err != nil {
return model.UserGroup{}, err return model.UserGroup{}, err
} }
// Fetch the users based on UserIDs in input // Fetch the users based on the userIds
var users []model.User var users []model.User
if len(input.UserIDs) > 0 { if len(userIds) > 0 {
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil { if err := s.db.Where("id IN (?)", userIds).Find(&users).Error; err != nil {
return model.UserGroup{}, err return model.UserGroup{}, err
} }
} }

View File

@@ -3,11 +3,16 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/url" "net/url"
"os"
"strings" "strings"
"time" "time"
"github.com/google/uuid"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
@@ -44,10 +49,83 @@ func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils
func (s *UserService) GetUser(userID string) (model.User, error) { func (s *UserService) GetUser(userID string) (model.User, error) {
var user model.User var user model.User
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error err := s.db.Preload("UserGroups").Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
return user, err return user, err
} }
func (s *UserService) GetProfilePicture(userID string) (io.Reader, int64, error) {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return nil, 0, &common.InvalidUUIDError{}
}
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
file, err := os.Open(profilePicturePath)
if err == nil {
// Get the file size
fileInfo, err := file.Stat()
if err != nil {
return nil, 0, err
}
return file, fileInfo.Size(), nil
}
// If the file does not exist, return the default profile picture
user, err := s.GetUser(userID)
if err != nil {
return nil, 0, err
}
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.FirstName, user.LastName)
if err != nil {
return nil, 0, err
}
return defaultPicture, int64(defaultPicture.Len()), nil
}
func (s *UserService) GetUserGroups(userID string) ([]model.UserGroup, error) {
var user model.User
if err := s.db.Preload("UserGroups").Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return user.UserGroups, nil
}
func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return &common.InvalidUUIDError{}
}
// Convert the image to a smaller square image
profilePicture, err := profilepicture.CreateProfilePicture(file)
if err != nil {
return err
}
// Ensure the directory exists
profilePictureDir := fmt.Sprintf("%s/profile-pictures", common.EnvConfig.UploadPath)
if err := os.MkdirAll(profilePictureDir, os.ModePerm); err != nil {
return err
}
// Create the profile picture file
createdProfilePicture, err := os.Create(fmt.Sprintf("%s/%s.png", profilePictureDir, userID))
if err != nil {
return err
}
defer createdProfilePicture.Close()
// Copy the image to the file
_, err = io.Copy(createdProfilePicture, profilePicture)
if err != nil {
return err
}
return nil
}
func (s *UserService) DeleteUser(userID string) error { func (s *UserService) DeleteUser(userID string) error {
var user model.User var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil { if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
@@ -59,6 +137,12 @@ func (s *UserService) DeleteUser(userID string) error {
return &common.LdapUserUpdateError{} return &common.LdapUserUpdateError{}
} }
// Delete the profile picture
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
if err := os.Remove(profilePicturePath); err != nil && !os.IsNotExist(err) {
return err
}
return s.db.Delete(&user).Error return s.db.Delete(&user).Error
} }
@@ -194,6 +278,33 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAg
return oneTimeAccessToken.User, accessToken, nil return oneTimeAccessToken.User, accessToken, nil
} }
func (s *UserService) UpdateUserGroups(id string, userGroupIds []string) (user model.User, err error) {
user, err = s.GetUser(id)
if err != nil {
return model.User{}, err
}
// Fetch the groups based on userGroupIds
var groups []model.UserGroup
if len(userGroupIds) > 0 {
if err := s.db.Where("id IN (?)", userGroupIds).Find(&groups).Error; err != nil {
return model.User{}, err
}
}
// Replace the current groups with the new set of groups
if err := s.db.Model(&user).Association("UserGroups").Replace(groups); err != nil {
return model.User{}, err
}
// Save the updated user
if err := s.db.Save(&user).Error; err != nil {
return model.User{}, err
}
return user, nil
}
func (s *UserService) SetupInitialAdmin() (model.User, string, error) { func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
var userCount int64 var userCount int64
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil { if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {

View File

@@ -0,0 +1,97 @@
package profilepicture
import (
"bytes"
"fmt"
"github.com/disintegration/imageorient"
"github.com/disintegration/imaging"
"github.com/pocket-id/pocket-id/backend/resources"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"image"
"image/color"
"io"
"strings"
)
const profilePictureSize = 300
// CreateProfilePicture resizes the profile picture to a square
func CreateProfilePicture(file io.Reader) (*bytes.Buffer, error) {
img, _, err := imageorient.Decode(file)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
img = imaging.Fill(img, profilePictureSize, profilePictureSize, imaging.Center, imaging.Lanczos)
var buf bytes.Buffer
err = imaging.Encode(&buf, img, imaging.PNG)
if err != nil {
return nil, fmt.Errorf("failed to encode image: %v", err)
}
return &buf, nil
}
// CreateDefaultProfilePicture creates a profile picture with the initials
func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, error) {
// Get the initials
initials := ""
if len(firstName) > 0 {
initials += string(firstName[0])
}
if len(lastName) > 0 {
initials += string(lastName[0])
}
initials = strings.ToUpper(initials)
// Create a blank image with a white background
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})
// Load the font
fontBytes, err := resources.FS.ReadFile("fonts/PlayfairDisplay-Bold.ttf")
if err != nil {
return nil, fmt.Errorf("failed to read font file: %w", err)
}
// Parse the font
fontFace, err := opentype.Parse(fontBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse font: %w", err)
}
// Create a font.Face with a specific size
fontSize := 160.0
face, err := opentype.NewFace(fontFace, &opentype.FaceOptions{
Size: fontSize,
DPI: 72,
})
if err != nil {
return nil, fmt.Errorf("failed to create font face: %w", err)
}
// Create a drawer for the image
drawer := &font.Drawer{
Dst: img,
Src: image.NewUniform(color.RGBA{R: 0, G: 0, B: 0, A: 255}), // Black text color
Face: face,
}
// Center the initials
x := (profilePictureSize - font.MeasureString(face, initials).Ceil()) / 2
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil() - 10
drawer.Dot = fixed.P(x, y)
// Draw the initials
drawer.DrawString(initials)
var buf bytes.Buffer
err = imaging.Encode(&buf, img, imaging.PNG)
if err != nil {
return nil, fmt.Errorf("failed to encode image: %v", err)
}
return &buf, nil
}

View File

@@ -0,0 +1,33 @@
package systemd
import (
"net"
"os"
)
// SdNotifyReady sends a message to the systemd daemon to notify that service is ready to operate.
// It is common to ignore the error.
func SdNotifyReady() error {
socketAddr := &net.UnixAddr{
Name: os.Getenv("NOTIFY_SOCKET"),
Net: "unixgram",
}
if socketAddr.Name == "" {
return nil
}
conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr)
if err != nil {
return err
}
defer func() {
_ = conn.Close()
}()
if _, err = conn.Write([]byte("READY=1")); err != nil {
return err
}
return nil
}

View File

@@ -1,95 +1,92 @@
{{ define "style" }} {{ define "style" }}
<style> <style>
body { /* Reset styles for email clients */
font-family: Arial, sans-serif; body, table, td, p, a {
background-color: #f0f0f0; margin: 0;
color: #333; padding: 0;
margin: 0; border: 0;
padding: 0; font-size: 100%;
} font-family: Arial, sans-serif;
.container { line-height: 1.5;
background-color: #fff; }
color: #333; body {
padding: 32px; background-color: #f0f0f0;
border-radius: 10px; color: #333;
max-width: 600px; }
margin: 40px auto; .container {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); width: 100%;
} max-width: 600px;
.header { margin: 40px auto;
display: flex; background-color: #fff;
justify-content: space-between; border-radius: 10px;
align-items: center; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 24px; padding: 32px;
} }
.header .logo { .header {
display: flex; display: flex;
align-items: center; margin-bottom: 24px;
gap: 8px; }
} .header .logo img {
.header .logo img { width: 32px;
width: 32px; height: 32px;
height: 32px; vertical-align: middle;
object-fit: cover; }
} .header h1 {
.header h1 { font-size: 1.5rem;
font-size: 1.5rem; font-weight: bold;
font-weight: bold; display: inline-block;
} vertical-align: middle;
.warning { margin-left: 8px;
background-color: #ffd966; }
color: #7f6000; .warning {
padding: 4px 12px; background-color: #ffd966;
border-radius: 50px; color: #7f6000;
font-size: 0.875rem; padding: 4px 12px;
} border-radius: 50px;
.content { font-size: 0.875rem;
background-color: #fafafa; margin: auto 0 auto auto;
color: #333; }
padding: 24px; .content {
border-radius: 10px; background-color: #fafafa;
} padding: 24px;
.content h2 { border-radius: 10px;
font-size: 1.25rem; }
font-weight: bold; .content h2 {
margin-bottom: 16px; font-size: 1.25rem;
} font-weight: bold;
.grid { margin-bottom: 16px;
display: grid; }
grid-template-columns: 1fr 1fr; .grid {
gap: 16px; width: 100%;
margin-bottom: 16px; margin-bottom: 16px;
} }
.grid div { .grid td {
display: flex; width: 50%;
flex-direction: column; padding-bottom: 8px;
} vertical-align: top;
.grid p { }
margin: 0; .label {
} color: #888;
.label { font-size: 0.875rem;
color: #888; }
font-size: 0.875rem; .message {
margin-bottom: 4px; font-size: 1rem;
} line-height: 1.5;
.message { margin-top: 16px;
font-size: 1rem; }
line-height: 1.5; .button {
} background-color: #000000;
.button { color: #ffffff;
border-radius: 0.375rem; padding: 0.7rem 1.5rem;
font-size: 1rem; text-decoration: none;
font-weight: 500; border-radius: 4px;
background-color: #000000; font-size: 1rem;
color: #ffffff; font-weight: 500;
padding: 0.7rem 1.5rem; display: inline-block;
outline: none; margin-top: 24px;
border: none; }
text-decoration: none; .button-container {
} text-align: center;
.button-container { }
text-align: center;
margin-top: 24px;
}
</style> </style>
{{ end }} {{ end }}

View File

@@ -1,36 +1,40 @@
{{ define "base" }} {{ define "base" }}
<div class="header"> <div class="header">
<div class="logo"> <div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/> <img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1> <h1>{{ .AppName }}</h1>
</div> </div>
<div class="warning">Warning</div> <div class="warning">Warning</div>
</div> </div>
<div class="content"> <div class="content">
<h2>New Sign-In Detected</h2> <h2>New Sign-In Detected</h2>
<div class="grid"> <table class="grid">
{{ if and .Data.City .Data.Country }} <tr>
<div> {{ if and .Data.City .Data.Country }}
<p class="label">Approximate Location</p> <td>
<p>{{ .Data.City }}, {{ .Data.Country }}</p> <p class="label">Approximate Location</p>
</div> <p>{{ .Data.City }}, {{ .Data.Country }}</p>
{{ end }} </td>
<div> {{ end }}
<p class="label">IP Address</p> <td>
<p>{{ .Data.IPAddress }}</p> <p class="label">IP Address</p>
</div> <p>{{ .Data.IPAddress }}</p>
<div> </td>
<p class="label">Device</p> </tr>
<p>{{ .Data.Device }}</p> <tr>
</div> <td>
<div> <p class="label">Device</p>
<p class="label">Sign-In Time</p> <p>{{ .Data.Device }}</p>
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p> </td>
</div> <td>
</div> <p class="label">Sign-In Time</p>
<p class="message"> <p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
This sign-in was detected from a new device or location. If you recognize this activity, you can </td>
safely ignore this message. If not, please review your account and security settings. </tr>
</p> </table>
</div> <p class="message">
This sign-in was detected from a new device or location. If you recognize this activity, you can
safely ignore this message. If not, please review your account and security settings.
</p>
</div>
{{ end -}} {{ end -}}

View File

@@ -4,5 +4,5 @@ import "embed"
// Embedded file systems for the project // Embedded file systems for the project
//go:embed email-templates images migrations //go:embed email-templates images migrations fonts
var FS embed.FS var FS embed.FS

Binary file not shown.

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls JSONB;

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET value = 'true' WHERE key = 'smtpTls';

View File

@@ -0,0 +1,7 @@
UPDATE app_config_variables AS target
SET value = CASE
WHEN target.value = 'true' AND (SELECT value FROM app_config_variables WHERE key = 'smtpPort' LIMIT 1) = '587' THEN 'starttls'
WHEN target.value = 'true' THEN 'tls'
ELSE 'none'
END
WHERE target.key = 'smtpTls';

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls BLOB;

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET value = 'true' WHERE key = 'smtpTls';

View File

@@ -0,0 +1,7 @@
UPDATE app_config_variables
SET value = CASE
WHEN value = 'true' AND (SELECT value FROM app_config_variables WHERE key = 'smtpPort' LIMIT 1) = '587' THEN 'starttls'
WHEN value = 'true' THEN 'tls'
ELSE 'none'
END
WHERE key = 'smtpTls';

View File

@@ -1,12 +1,12 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.30.0", "version": "0.35.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.30.0", "version": "0.35.2",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.32.0", "version": "0.36.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -12,7 +12,11 @@ process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
if (event.url.pathname.startsWith('/settings') && !event.url.pathname.startsWith('/login')) { const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login');
const isPublicPath = ['/authorize', '/health'].includes(event.url.pathname);
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
if (!isUnauthenticatedOnlyPath && !isPublicPath) {
if (!isSignedIn) { if (!isSignedIn) {
return new Response(null, { return new Response(null, {
status: 302, status: 302,
@@ -21,14 +25,14 @@ export const handle: Handle = async ({ event, resolve }) => {
} }
} }
if (event.url.pathname.startsWith('/login') && isSignedIn) { if (isUnauthenticatedOnlyPath && isSignedIn) {
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { location: '/settings' } headers: { location: '/settings' }
}); });
} }
if (event.url.pathname.startsWith('/settings/admin') && !isAdmin) { if (isAdminPath && !isAdmin) {
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { location: '/settings' } headers: { location: '/settings' }

View File

@@ -27,15 +27,13 @@
} }
</script> </script>
<button onclick={onClick}> <Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}> <Tooltip.Trigger class="text-start" onclick={onClick}>{@render children()}</Tooltip.Trigger>
<Tooltip.Trigger>{@render children()}</Tooltip.Trigger> <Tooltip.Content onclick={copyToClipboard}>
<Tooltip.Content onclick={copyToClipboard}> {#if copied}
{#if copied} <span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span> {:else}
{:else} <span>Click to copy</span>
<span>Click to copy</span> {/if}
{/if} </Tooltip.Content>
</Tooltip.Content> </Tooltip.Root>
</Tooltip.Root>
</button>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Checkbox } from './ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from './ui/label'; import { Label } from '$lib/components/ui/label';
let { let {
id, id,
@@ -31,7 +31,7 @@
{label} {label}
</Label> </Label>
{#if description} {#if description}
<p class="text-[0.8rem] text-muted-foreground"> <p class="text-muted-foreground text-[0.8rem]">
{description} {description}
</p> </p>
{/if} {/if}

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import CustomClaimService from '$lib/services/custom-claim-service'; import CustomClaimService from '$lib/services/custom-claim-service';

View File

@@ -2,7 +2,7 @@
import { cn } from '$lib/utils/style'; import { cn } from '$lib/utils/style';
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
import type { VariantProps } from 'tailwind-variants'; import type { VariantProps } from 'tailwind-variants';
import type { buttonVariants } from './ui/button'; import type { buttonVariants } from '$lib/components/ui/button';
let { let {
id, id,

View File

@@ -3,7 +3,7 @@
import type { FormInput } from '$lib/utils/form-util'; import type { FormInput } from '$lib/utils/form-util';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { Input, type FormInputEvent } from './ui/input'; import { Input, type FormInputEvent } from '$lib/components/ui/input';
let { let {
input = $bindable(), input = $bindable(),

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { LucideLoader, LucideUpload } from 'lucide-svelte';
let {
userId,
isLdapUser = false,
callback
}: {
userId: string;
isLdapUser?: boolean;
callback: (image: File) => Promise<void>;
} = $props();
let isLoading = $state(false);
let imageDataURL = $state(`/api/users/${userId}/profile-picture.png`);
async function onImageChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] || null;
if (!file) return;
isLoading = true;
const reader = new FileReader();
reader.onload = (event) => {
imageDataURL = event.target?.result as string;
};
reader.readAsDataURL(file);
await callback(file).catch(() => {
imageDataURL = `/api/users/${userId}/profile-picture.png`;
});
isLoading = false;
}
</script>
<div class="flex gap-5">
<div class="flex w-full flex-col justify-between gap-5 sm:flex-row">
<div>
<h3 class="text-xl font-semibold">Profile Picture</h3>
{#if isLdapUser}
<p class="text-muted-foreground mt-1 text-sm">
The profile picture is managed by the LDAP server and cannot be changed here.
</p>
{:else}
<p class="text-muted-foreground mt-1 text-sm">
Click on the profile picture to upload a custom one from your files.
</p>
<p class="text-muted-foreground mt-1 text-sm">The image should be in PNG or JPEG format.</p>
{/if}
</div>
{#if isLdapUser}
<Avatar.Root class="h-24 w-24">
<Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root>
{:else}
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-28 w-28 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
{:else}
<LucideUpload class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100" />
{/if}
</div>
</div>
</FileInput>
{/if}
</div>
</div>

View File

@@ -3,22 +3,10 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { createSHA256hash } from '$lib/utils/crypto-util';
import { LucideLogOut, LucideUser } from 'lucide-svelte'; import { LucideLogOut, LucideUser } from 'lucide-svelte';
const webauthnService = new WebAuthnService(); const webauthnService = new WebAuthnService();
let initials = $derived(
($userStore!.firstName.charAt(0) + $userStore!.lastName?.charAt(0)).toUpperCase()
);
let gravatarURL: string | undefined = $state();
if ($userStore) {
createSHA256hash($userStore.email).then((email) => {
gravatarURL = `https://www.gravatar.com/avatar/${email}?d=404`;
});
}
async function logout() { async function logout() {
await webauthnService.logout(); await webauthnService.logout();
window.location.reload(); window.location.reload();
@@ -28,8 +16,7 @@
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
><Avatar.Root class="h-9 w-9"> ><Avatar.Root class="h-9 w-9">
<Avatar.Image src={gravatarURL} /> <Avatar.Image src="/api/users/me/profile-picture.png" />
<Avatar.Fallback>{initials}</Avatar.Fallback>
</Avatar.Root></DropdownMenu.Trigger </Avatar.Root></DropdownMenu.Trigger
> >
<DropdownMenu.Content class="min-w-40" align="start"> <DropdownMenu.Content class="min-w-40" align="start">
@@ -39,7 +26,7 @@
{$userStore?.firstName} {$userStore?.firstName}
{$userStore?.lastName} {$userStore?.lastName}
</p> </p>
<p class="text-xs leading-none text-muted-foreground">{$userStore?.email}</p> <p class="text-muted-foreground text-xs leading-none">{$userStore?.email}</p>
</div> </div>
</DropdownMenu.Label> </DropdownMenu.Label>
<DropdownMenu.Separator /> <DropdownMenu.Separator />

View File

@@ -5,9 +5,10 @@
import Logo from '../logo.svelte'; import Logo from '../logo.svelte';
import HeaderAvatar from './header-avatar.svelte'; import HeaderAvatar from './header-avatar.svelte';
const authUrls = [/^\/authorize$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
let isAuthPage = $derived( let isAuthPage = $derived(
!$page.error && !$page.error && authUrls.some((pattern) => pattern.test($page.url.pathname))
($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
); );
</script> </script>

View File

@@ -11,7 +11,7 @@
<AvatarPrimitive.Root <AvatarPrimitive.Root
{delayMs} {delayMs}
class={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)} class={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full border', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View File

@@ -2,7 +2,6 @@
import AdvancedTable from '$lib/components/advanced-table.svelte'; import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import UserGroupService from '$lib/services/user-group-service'; import UserGroupService from '$lib/services/user-group-service';
import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated } from '$lib/types/pagination.type'; import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type'; import type { UserGroup } from '$lib/types/user-group.type';

View File

@@ -95,7 +95,7 @@ export default class AppConfigService extends APIService {
return true; return true;
} else if (value === 'false') { } else if (value === 'false') {
return false; return false;
} else if (!isNaN(parseFloat(value))) { } else if (/^-?\d+(\.\d+)?$/.test(value)) {
return parseFloat(value); return parseFloat(value);
} else { } else {
return value; return value;

View File

@@ -1,4 +1,5 @@
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';
import APIService from './api-service'; import APIService from './api-service';
@@ -25,6 +26,11 @@ export default class UserService extends APIService {
return res.data as User; return res.data as User;
} }
async getUserGroups(userId: string) {
const res = await this.api.get(`/users/${userId}/groups`);
return res.data as UserGroup[];
}
async update(id: string, user: UserCreate) { async update(id: string, user: UserCreate) {
const res = await this.api.put(`/users/${id}`, user); const res = await this.api.put(`/users/${id}`, user);
return res.data as User; return res.data as User;
@@ -39,6 +45,20 @@ export default class UserService extends APIService {
await this.api.delete(`/users/${id}`); await this.api.delete(`/users/${id}`);
} }
async updateProfilePicture(userId: string, image: File) {
const formData = new FormData();
formData.append('file', image!);
await this.api.put(`/users/${userId}/profile-picture`, formData);
}
async updateCurrentUsersProfilePicture(image: File) {
const formData = new FormData();
formData.append('file', image!);
await this.api.put('/users/me/profile-picture', formData);
}
async createOneTimeAccessToken(userId: string, expiresAt: Date) { async createOneTimeAccessToken(userId: string, expiresAt: Date) {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, { const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
userId, userId,
@@ -55,4 +75,9 @@ export default class UserService extends APIService {
async requestOneTimeAccessEmail(email: string, redirectPath?: string) { async requestOneTimeAccessEmail(email: string, redirectPath?: string) {
await this.api.post('/one-time-access-email', { email, redirectPath }); await this.api.post('/one-time-access-email', { email, redirectPath });
} }
async updateUserGroups(id: string, userGroupIds: string[]) {
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
return res.data as User;
}
} }

View File

@@ -15,7 +15,7 @@ export type AllAppConfig = AppConfig & {
smtpFrom: string; smtpFrom: string;
smtpUser: string; smtpUser: string;
smtpPassword: string; smtpPassword: string;
smtpTls: boolean; smtpTls: 'none' | 'starttls' | 'tls';
smtpSkipCertVerify: boolean; smtpSkipCertVerify: boolean;
emailLoginNotificationEnabled: boolean; emailLoginNotificationEnabled: boolean;
// LDAP // LDAP
@@ -31,6 +31,8 @@ export type AllAppConfig = AppConfig & {
ldapAttributeUserEmail: string; ldapAttributeUserEmail: string;
ldapAttributeUserFirstName: string; ldapAttributeUserFirstName: string;
ldapAttributeUserLastName: string; ldapAttributeUserLastName: string;
ldapAttributeUserProfilePicture: string;
ldapAttributeGroupMember: string;
ldapAttributeGroupUniqueIdentifier: string; ldapAttributeGroupUniqueIdentifier: string;
ldapAttributeGroupName: string; ldapAttributeGroupName: string;
ldapAttributeAdminGroup: string; ldapAttributeAdminGroup: string;
@@ -45,5 +47,5 @@ export type AppConfigRawResponse = {
export type AppVersionInformation = { export type AppVersionInformation = {
isUpToDate: boolean | null; isUpToDate: boolean | null;
newestVersion: string | null; newestVersion: string | null;
currentVersion: string currentVersion: string;
}; };

View File

@@ -5,6 +5,7 @@ export type OidcClient = {
name: string; name: string;
logoURL: string; logoURL: string;
callbackURLs: [string, ...string[]]; callbackURLs: [string, ...string[]];
logoutCallbackURLs: string[];
hasLogo: boolean; hasLogo: boolean;
isPublic: boolean; isPublic: boolean;
pkceEnabled: boolean; pkceEnabled: boolean;

View File

@@ -1,4 +1,5 @@
import type { CustomClaim } from './custom-claim.type'; import type { CustomClaim } from './custom-claim.type';
import type { UserGroup } from './user-group.type';
export type User = { export type User = {
id: string; id: string;
@@ -7,6 +8,7 @@ export type User = {
firstName: string; firstName: string;
lastName: string; lastName: string;
isAdmin: boolean; isAdmin: boolean;
userGroups: UserGroup[];
customClaims: CustomClaim[]; customClaims: CustomClaim[];
ldapId?: string; ldapId?: string;
}; };

View File

@@ -8,7 +8,6 @@
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte'; import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
@@ -60,11 +59,7 @@
onSuccess(code, callbackURL); onSuccess(code, callbackURL);
}); });
} catch (e) { } catch (e) {
if (e instanceof AxiosError && e.response?.data.error === 'Missing authorization') { errorMessage = getWebauthnErrorMessage(e);
authorizationRequired = true;
} else {
errorMessage = getWebauthnErrorMessage(e);
}
isLoading = false; isLoading = false;
} }
} }

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store.js';
import { axiosErrorToast } from '$lib/utils/error-util.js';
let isLoading = $state(false);
const webauthnService = new WebAuthnService();
async function signOut() {
isLoading = true;
await webauthnService
.logout()
.then(() => goto('/'))
.catch(axiosErrorToast);
isLoading = false;
}
</script>
<svelte:head>
<title>Logout</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">Sign out</h1>
<p class="text-muted-foreground mt-2">
Do you want to sign out of Pocket ID with the account <b>{$userStore?.username}</b>?
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button class="w-full" variant="secondary" onclick={() => history.back()}>Cancel</Button>
<Button class="w-full" {isLoading} onclick={signOut}>Sign out</Button>
</div>
</SignInWrapper>

View File

@@ -33,41 +33,37 @@
</script> </script>
<section> <section>
<div class="flex min-h-[calc(100vh-64px)] w-full flex-col justify-between bg-muted/40"> <div class="bg-muted/40 flex min-h-[calc(100vh-64px)] w-full flex-col justify-between">
<main <main
class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row" class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row"
> >
<div> <div class="min-w-[200px] xl:min-w-[250px]">
<div class="mx-auto grid w-full gap-2"> <div class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">Settings</h1> <h1 class="mb-5 text-3xl font-semibold">Settings</h1>
</div> </div>
<div <nav class="text-muted-foreground grid gap-4 text-sm">
class="mx-auto grid items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]" {#each links as { href, label }}
> <a {href} class={$page.url.pathname.startsWith(href) ? 'text-primary font-bold' : ''}>
<nav class="grid gap-4 text-sm text-muted-foreground"> {label}
{#each links as { href, label }} </a>
<a {href} class={$page.url.pathname.startsWith(href) ? 'font-bold text-primary' : ''}> {/each}
{label} {#if $userStore?.isAdmin && versionInformation.isUpToDate === false}
</a> <a
{/each} href="https://github.com/pocket-id/pocket-id/releases/latest"
{#if $userStore?.isAdmin && versionInformation.isUpToDate === false} target="_blank"
<a class="flex items-center gap-2"
href="https://github.com/pocket-id/pocket-id/releases/latest" >
target="_blank" Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" />
class="flex items-center gap-2" </a>
> {/if}
Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" /> </nav>
</a>
{/if}
</nav>
</div>
</div> </div>
<div class="flex w-full flex-col gap-5 overflow-x-hidden"> <div class="flex w-full flex-col gap-5 overflow-x-hidden">
{@render children()} {@render children()}
</div> </div>
</main> </main>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<p class="py-3 text-xs text-muted-foreground"> <p class="text-muted-foreground py-3 text-xs">
Powered by <a Powered by <a
class="text-foreground" class="text-foreground"
href="https://github.com/pocket-id/pocket-id" href="https://github.com/pocket-id/pocket-id"

View File

@@ -13,6 +13,7 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import AccountForm from './account-form.svelte'; import AccountForm from './account-form.svelte';
import PasskeyList from './passkey-list.svelte'; import PasskeyList from './passkey-list.svelte';
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
import RenamePasskeyModal from './rename-passkey-modal.svelte'; import RenamePasskeyModal from './rename-passkey-modal.svelte';
let { data } = $props(); let { data } = $props();
@@ -36,6 +37,13 @@
return success; return success;
} }
async function updateProfilePicture(image: File) {
await userService
.updateCurrentUsersProfilePicture(image)
.then(() => toast.success('Profile picture updated successfully'))
.catch(axiosErrorToast);
}
async function createPasskey() { async function createPasskey() {
try { try {
const opts = await webauthnService.getRegistrationOptions(); const opts = await webauthnService.getRegistrationOptions();
@@ -86,6 +94,12 @@
</Card.Root> </Card.Root>
</fieldset> </fieldset>
<Card.Root>
<Card.Content class="pt-6">
<ProfilePictureSettings userId="me" isLdapUser={!!account.ldapId} callback={updateProfilePicture} />
</Card.Content>
</Card.Root>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { env } from '$env/dynamic/public';
import CollapsibleCard from '$lib/components/collapsible-card.svelte'; import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
@@ -14,7 +13,6 @@
let { data } = $props(); let { data } = $props();
let appConfig = $state(data.appConfig); let appConfig = $state(data.appConfig);
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) { async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
@@ -57,28 +55,26 @@
<title>Application Configuration</title> <title>Application Configuration</title>
</svelte:head> </svelte:head>
<fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}> <CollapsibleCard id="application-configuration-general" title="General" defaultExpanded>
<CollapsibleCard id="application-configuration-general" title="General" defaultExpanded> <AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} /> </CollapsibleCard>
</CollapsibleCard>
<CollapsibleCard <CollapsibleCard
id="application-configuration-email" id="application-configuration-email"
title="Email" title="Email"
description="Enable email notifications to alert users when a login is detected from a new device or description="Enable email notifications to alert users when a login is detected from a new device or
location." location."
> >
<AppConfigEmailForm {appConfig} callback={updateAppConfig} /> <AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard> </CollapsibleCard>
<CollapsibleCard <CollapsibleCard
id="application-configuration-ldap" id="application-configuration-ldap"
title="LDAP" title="LDAP"
description="Configure LDAP settings to sync users and groups from an LDAP server." description="Configure LDAP settings to sync users and groups from an LDAP server."
> >
<AppConfigLdapForm {appConfig} callback={updateAppConfig} /> <AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard> </CollapsibleCard>
</fieldset>
<CollapsibleCard id="application-configuration-images" title="Images"> <CollapsibleCard id="application-configuration-images" title="Images">
<UpdateApplicationImages callback={updateImages} /> <UpdateApplicationImages callback={updateImages} />

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import FileInput from '$lib/components/file-input.svelte'; import FileInput from '$lib/components/form/file-input.svelte';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { cn } from '$lib/utils/style'; import { cn } from '$lib/utils/style';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';

View File

@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte'; import { env } from '$env/dynamic/public';
import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { openConfirmDialog } from '$lib/components/confirm-dialog';
import FormInput from '$lib/components/form-input.svelte'; import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
@@ -18,6 +21,12 @@
} = $props(); } = $props();
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
const tlsOptions = {
none: 'None',
starttls: 'StartTLS',
tls: 'TLS'
};
let isSendingTestEmail = $state(false); let isSendingTestEmail = $state(false);
@@ -27,7 +36,7 @@
smtpUser: z.string(), smtpUser: z.string(),
smtpPassword: z.string(), smtpPassword: z.string(),
smtpFrom: z.string().email(), smtpFrom: z.string().email(),
smtpTls: z.boolean(), smtpTls: z.enum(['none', 'starttls', 'tls']),
smtpSkipCertVerify: z.boolean(), smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessEnabled: z.boolean(), emailOneTimeAccessEnabled: z.boolean(),
emailLoginNotificationEnabled: z.boolean() emailLoginNotificationEnabled: z.boolean()
@@ -86,46 +95,57 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<h4 class="text-lg font-semibold">SMTP Configuration</h4> <fieldset disabled={uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2"> <h4 class="text-lg font-semibold">SMTP Configuration</h4>
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} /> <div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} /> <FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<CheckboxWithLabel <FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
id="tls" <div class="grid gap-2">
label="TLS" <Label class="mb-0" for="smtp-tls">SMTP TLS Option</Label>
description="Enable TLS for the SMTP connection." <Select.Root
bind:checked={$inputs.smtpTls.value} selected={{ value: $inputs.smtpTls.value, label: tlsOptions[$inputs.smtpTls.value] }}
/> onSelectedChange={(v) => ($inputs.smtpTls.value = v!.value)}
<CheckboxWithLabel >
id="skip-cert-verify" <Select.Trigger>
label="Skip Certificate Verification" <Select.Value placeholder="Email TLS Option" />
description="This can be useful for self-signed certificates." </Select.Trigger>
bind:checked={$inputs.smtpSkipCertVerify.value} <Select.Content>
/> <Select.Item value="none" label="None" />
</div> <Select.Item value="starttls" label="StartTLS" />
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4> <Select.Item value="tls" label="TLS" />
<div class="mt-4 flex flex-col gap-5"> </Select.Content>
<CheckboxWithLabel </Select.Root>
id="email-login-notification" </div>
label="Email Login Notification" <CheckboxWithLabel
description="Send an email to the user when they log in from a new device." id="skip-cert-verify"
bind:checked={$inputs.emailLoginNotificationEnabled.value} label="Skip Certificate Verification"
/> description="This can be useful for self-signed certificates."
<CheckboxWithLabel bind:checked={$inputs.smtpSkipCertVerify.value}
id="email-one-time-access" />
label="Email One Time Access" </div>
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry." <h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
bind:checked={$inputs.emailOneTimeAccessEnabled.value} <div class="mt-4 flex flex-col gap-5">
/> <CheckboxWithLabel
</div> id="email-login-notification"
label="Email Login Notification"
description="Send an email to the user when they log in from a new device."
bind:checked={$inputs.emailLoginNotificationEnabled.value}
/>
<CheckboxWithLabel
id="email-one-time-access"
label="Email One Time Access"
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/>
</div>
</fieldset>
<div class="mt-8 flex flex-wrap justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail} <Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send test email</Button >Send test email</Button
> >
<Button type="submit">Save</Button> <Button type="submit" disabled={uiConfigDisabled}>Save</Button>
</div> </div>
</form> </form>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte'; import { env } from '$env/dynamic/public';
import FormInput from '$lib/components/form-input.svelte'; import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
@@ -15,6 +16,7 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>; callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props(); } = $props();
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
let isLoading = $state(false); let isLoading = $state(false);
const updatedAppConfig = { const updatedAppConfig = {
@@ -42,28 +44,30 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="flex flex-col gap-5"> <fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}>
<FormInput label="Application Name" bind:input={$inputs.appName} /> <div class="flex flex-col gap-5">
<FormInput <FormInput label="Application Name" bind:input={$inputs.appName} />
label="Session Duration" <FormInput
type="number" label="Session Duration"
description="The duration of a session in minutes before the user has to sign in again." type="number"
bind:input={$inputs.sessionDuration} description="The duration of a session in minutes before the user has to sign in again."
/> bind:input={$inputs.sessionDuration}
<CheckboxWithLabel />
id="self-account-editing" <CheckboxWithLabel
label="Enable Self-Account Editing" id="self-account-editing"
description="Whether the users should be able to edit their own account details." label="Enable Self-Account Editing"
bind:checked={$inputs.allowOwnAccountEdit.value} description="Whether the users should be able to edit their own account details."
/> bind:checked={$inputs.allowOwnAccountEdit.value}
<CheckboxWithLabel />
id="emails-verified" <CheckboxWithLabel
label="Emails Verified" id="emails-verified"
description="Whether the user's email should be marked as verified for the OIDC clients." label="Emails Verified"
bind:checked={$inputs.emailsVerified.value} description="Whether the user's email should be marked as verified for the OIDC clients."
/> bind:checked={$inputs.emailsVerified.value}
</div> />
<div class="mt-5 flex justify-end"> </div>
<Button {isLoading} type="submit">Save</Button> <div class="mt-5 flex justify-end">
</div> <Button {isLoading} type="submit">Save</Button>
</div>
</fieldset>
</form> </form>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte'; import { env } from '$env/dynamic/public';
import FormInput from '$lib/components/form-input.svelte'; import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
@@ -18,6 +19,7 @@
} = $props(); } = $props();
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
let ldapEnabled = $state(appConfig.ldapEnabled); let ldapEnabled = $state(appConfig.ldapEnabled);
let ldapSyncing = $state(false); let ldapSyncing = $state(false);
@@ -36,6 +38,8 @@
ldapAttributeUserEmail: appConfig.ldapAttributeUserEmail, ldapAttributeUserEmail: appConfig.ldapAttributeUserEmail,
ldapAttributeUserFirstName: appConfig.ldapAttributeUserFirstName, ldapAttributeUserFirstName: appConfig.ldapAttributeUserFirstName,
ldapAttributeUserLastName: appConfig.ldapAttributeUserLastName, ldapAttributeUserLastName: appConfig.ldapAttributeUserLastName,
ldapAttributeUserProfilePicture: appConfig.ldapAttributeUserProfilePicture,
ldapAttributeGroupMember: appConfig.ldapAttributeGroupMember,
ldapAttributeGroupUniqueIdentifier: appConfig.ldapAttributeGroupUniqueIdentifier, ldapAttributeGroupUniqueIdentifier: appConfig.ldapAttributeGroupUniqueIdentifier,
ldapAttributeGroupName: appConfig.ldapAttributeGroupName, ldapAttributeGroupName: appConfig.ldapAttributeGroupName,
ldapAttributeAdminGroup: appConfig.ldapAttributeAdminGroup ldapAttributeAdminGroup: appConfig.ldapAttributeAdminGroup
@@ -54,6 +58,8 @@
ldapAttributeUserEmail: z.string().min(1), ldapAttributeUserEmail: z.string().min(1),
ldapAttributeUserFirstName: z.string().min(1), ldapAttributeUserFirstName: z.string().min(1),
ldapAttributeUserLastName: z.string().min(1), ldapAttributeUserLastName: z.string().min(1),
ldapAttributeUserProfilePicture: z.string(),
ldapAttributeGroupMember: z.string(),
ldapAttributeGroupUniqueIdentifier: z.string().min(1), ldapAttributeGroupUniqueIdentifier: z.string().min(1),
ldapAttributeGroupName: z.string().min(1), ldapAttributeGroupName: z.string().min(1),
ldapAttributeAdminGroup: z.string() ldapAttributeAdminGroup: z.string()
@@ -97,88 +103,110 @@
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<h4 class="text-lg font-semibold">Client Configuration</h4> <h4 class="text-lg font-semibold">Client Configuration</h4>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <fieldset disabled={uiConfigDisabled}>
<FormInput label="LDAP URL" placeholder="ldap://example.com:389" bind:input={$inputs.ldapUrl} /> <div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput <FormInput
label="LDAP Bind DN" label="LDAP URL"
placeholder="cn=people,dc=example,dc=com" placeholder="ldap://example.com:389"
bind:input={$inputs.ldapBindDn} bind:input={$inputs.ldapUrl}
/> />
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} /> <FormInput
<FormInput label="LDAP Base DN" placeholder="dc=example,dc=com" bind:input={$inputs.ldapBase} /> label="LDAP Bind DN"
<FormInput placeholder="cn=people,dc=example,dc=com"
label="User Search Filter" bind:input={$inputs.ldapBindDn}
description="The Search filter to use to search/sync users." />
placeholder="(objectClass=person)" <FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} />
bind:input={$inputs.ldapUserSearchFilter} <FormInput
/> label="LDAP Base DN"
<FormInput placeholder="dc=example,dc=com"
label="Groups Search Filter" bind:input={$inputs.ldapBase}
description="The Search filter to use to search/sync groups." />
placeholder="(objectClass=groupOfNames)" <FormInput
bind:input={$inputs.ldapUserGroupSearchFilter} label="User Search Filter"
/> description="The Search filter to use to search/sync users."
<CheckboxWithLabel placeholder="(objectClass=person)"
id="skip-cert-verify" bind:input={$inputs.ldapUserSearchFilter}
label="Skip Certificate Verification" />
description="This can be useful for self-signed certificates." <FormInput
bind:checked={$inputs.ldapSkipCertVerify.value} label="Groups Search Filter"
/> description="The Search filter to use to search/sync groups."
</div> placeholder="(objectClass=groupOfNames)"
<h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4> bind:input={$inputs.ldapUserGroupSearchFilter}
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2"> />
<FormInput <CheckboxWithLabel
label="User Unique Identifier Attribute" id="skip-cert-verify"
description="The value of this attribute should never change." label="Skip Certificate Verification"
placeholder="uuid" description="This can be useful for self-signed certificates."
bind:input={$inputs.ldapAttributeUserUniqueIdentifier} bind:checked={$inputs.ldapSkipCertVerify.value}
/> />
<FormInput </div>
label="Username Attribute" <h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4>
placeholder="uid" <div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
bind:input={$inputs.ldapAttributeUserUsername} <FormInput
/> label="User Unique Identifier Attribute"
<FormInput description="The value of this attribute should never change."
label="User Mail Attribute" placeholder="uuid"
placeholder="mail" bind:input={$inputs.ldapAttributeUserUniqueIdentifier}
bind:input={$inputs.ldapAttributeUserEmail} />
/> <FormInput
<FormInput label="Username Attribute"
label="User First Name Attribute" placeholder="uid"
placeholder="givenName" bind:input={$inputs.ldapAttributeUserUsername}
bind:input={$inputs.ldapAttributeUserFirstName} />
/> <FormInput
<FormInput label="User Mail Attribute"
label="User Last Name Attribute" placeholder="mail"
placeholder="sn" bind:input={$inputs.ldapAttributeUserEmail}
bind:input={$inputs.ldapAttributeUserLastName} />
/> <FormInput
<FormInput label="User First Name Attribute"
label="Group Unique Identifier Attribute" placeholder="givenName"
description="The value of this attribute should never change." bind:input={$inputs.ldapAttributeUserFirstName}
placeholder="uuid" />
bind:input={$inputs.ldapAttributeGroupUniqueIdentifier} <FormInput
/> label="User Last Name Attribute"
<FormInput placeholder="sn"
label="Group Name Attribute" bind:input={$inputs.ldapAttributeUserLastName}
placeholder="cn" />
bind:input={$inputs.ldapAttributeGroupName} <FormInput
/> label="User Profile Picture Attribute"
<FormInput description="The value of this attribute can either be a URL, a binary or a base64 encoded image."
label="Admin Group Name" placeholder="jpegPhoto"
description="Members of this group will have Admin Privileges in Pocket ID." bind:input={$inputs.ldapAttributeUserProfilePicture}
placeholder="_admin_group_name" />
bind:input={$inputs.ldapAttributeAdminGroup} <FormInput
/> label="Group Members Attribute"
</div> description="The attribute to use for querying members of a group."
placeholder="member"
bind:input={$inputs.ldapAttributeGroupMember}
/>
<FormInput
label="Group Unique Identifier Attribute"
description="The value of this attribute should never change."
placeholder="uuid"
bind:input={$inputs.ldapAttributeGroupUniqueIdentifier}
/>
<FormInput
label="Group Name Attribute"
placeholder="cn"
bind:input={$inputs.ldapAttributeGroupName}
/>
<FormInput
label="Admin Group Name"
description="Members of this group will have Admin Privileges in Pocket ID."
placeholder="_admin_group_name"
bind:input={$inputs.ldapAttributeAdminGroup}
/>
</div>
</fieldset>
<div class="mt-8 flex flex-wrap justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
{#if ldapEnabled} {#if ldapEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button> <Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>Disable</Button>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>Sync now</Button> <Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>Sync now</Button>
<Button type="submit">Save</Button> <Button type="submit" disabled={uiConfigDisabled}>Save</Button>
{:else} {:else}
<Button onclick={onEnable}>Enable</Button> <Button onclick={onEnable} disabled={uiConfigDisabled}>Enable</Button>
{/if} {/if}
</div> </div>
</form> </form>

View File

@@ -1,9 +1,24 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import OIDCService from '$lib/services/oidc-service'; import OIDCService from '$lib/services/oidc-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const oidcService = new OIDCService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const oidcService = new OIDCService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const clients = await oidcService.listClients();
// Create request options with default sorting
const requestOptions: SearchPaginationSortRequest = {
sort: {
column: 'name',
direction: 'asc'
},
pagination: {
page: 1,
limit: 10
}
};
const clients = await oidcService.listClients(requestOptions);
return clients; return clients;
}; };

View File

@@ -7,6 +7,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import OidcService from '$lib/services/oidc-service'; import OidcService from '$lib/services/oidc-service';
import UserGroupService from '$lib/services/user-group-service'; import UserGroupService from '$lib/services/user-group-service';
import clientSecretStore from '$lib/stores/client-secret-store'; import clientSecretStore from '$lib/stores/client-secret-store';
@@ -16,7 +17,6 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte'; import OidcForm from '../oidc-client-form.svelte';
import UserGroupSelection from '../user-group-selection.svelte';
let { data } = $props(); let { data } = $props();
let client = $state({ let client = $state({
@@ -33,6 +33,7 @@
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`, 'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`, 'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`, 'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Logout URL': `https://${$page.url.hostname}/api/oidc/end-session`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`, 'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled' PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
}); });
@@ -112,15 +113,15 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="mb-2 flex"> <div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">Client ID</Label> <Label class="mb-0 w-44">Client ID</Label>
<CopyToClipboard value={client.id}> <CopyToClipboard value={client.id}>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span> <span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard> </CopyToClipboard>
</div> </div>
{#if !client.isPublic} {#if !client.isPublic}
<div class="mb-2 mt-1 flex items-center"> <div class="mb-2 mt-1 flex flex-col sm:flex-row sm:items-center">
<Label class="w-44">Client secret</Label> <Label class="mb-0 w-44">Client secret</Label>
{#if $clientSecretStore} {#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}> <CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret"> <span class="text-muted-foreground text-sm" data-testid="client-secret">
@@ -128,23 +129,25 @@
</span> </span>
</CopyToClipboard> </CopyToClipboard>
{:else} {:else}
<span class="text-muted-foreground text-sm" data-testid="client-secret" <div>
>••••••••••••••••••••••••••••••••</span <span class="text-muted-foreground text-sm" data-testid="client-secret"
> >••••••••••••••••••••••••••••••••</span
<Button >
class="ml-2" <Button
onclick={createClientSecret} class="ml-2"
size="sm" onclick={createClientSecret}
variant="ghost" size="sm"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button variant="ghost"
> aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if showAllDetails} {#if showAllDetails}
<div transition:slide> <div transition:slide>
{#each Object.entries(setupDetails) as [key, value]} {#each Object.entries(setupDetails) as [key, value]}
<div class="mb-5 flex"> <div class="mb-5 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">{key}</Label> <Label class="mb-0 w-44">{key}</Label>
<CopyToClipboard {value}> <CopyToClipboard {value}>
<span class="text-muted-foreground text-sm">{value}</span> <span class="text-muted-foreground text-sm">{value}</span>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { LucideMinus, LucidePlus } from 'lucide-svelte'; import { LucideMinus, LucidePlus } from 'lucide-svelte';
@@ -7,12 +7,16 @@
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
let { let {
label,
callbackURLs = $bindable(), callbackURLs = $bindable(),
error = $bindable(null), error = $bindable(null),
allowEmpty = false,
...restProps ...restProps
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
label: string;
callbackURLs: string[]; callbackURLs: string[];
error?: string | null; error?: string | null;
allowEmpty?: boolean;
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
@@ -20,12 +24,12 @@
</script> </script>
<div {...restProps}> <div {...restProps}>
<FormInput label="Callback URLs"> <FormInput {label}>
<div class="flex flex-col gap-y-2"> <div class="flex flex-col gap-y-2">
{#each callbackURLs as _, i} {#each callbackURLs as _, i}
<div class="flex gap-x-2"> <div class="flex gap-x-2">
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} /> <Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
{#if callbackURLs.length > 1} {#if callbackURLs.length > 1 || allowEmpty}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -49,7 +53,7 @@
on:click={() => (callbackURLs = [...callbackURLs, ''])} on:click={() => (callbackURLs = [...callbackURLs, ''])}
> >
<LucidePlus class="mr-1 h-4 w-4" /> <LucidePlus class="mr-1 h-4 w-4" />
Add another {callbackURLs.length === 0 ? 'Add' : 'Add another'}
</Button> </Button>
{/if} {/if}
</div> </div>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte'; import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FileInput from '$lib/components/file-input.svelte'; import FileInput from '$lib/components/form/file-input.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import type { import type {
@@ -10,7 +10,7 @@
OidcClientCreateWithLogo OidcClientCreateWithLogo
} from '$lib/types/oidc.type'; } from '$lib/types/oidc.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { set, z } from 'zod'; import { z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte'; import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
let { let {
@@ -30,6 +30,7 @@
const client: OidcClientCreate = { const client: OidcClientCreate = {
name: existingClient?.name || '', name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [''], callbackURLs: existingClient?.callbackURLs || [''],
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false, isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || false pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || false
}; };
@@ -37,6 +38,7 @@
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
callbackURLs: z.array(z.string()).nonempty(), callbackURLs: z.array(z.string()).nonempty(),
logoutCallbackURLs: z.array(z.string()),
isPublic: z.boolean(), isPublic: z.boolean(),
pkceEnabled: z.boolean() pkceEnabled: z.boolean()
}); });
@@ -76,13 +78,22 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="grid grid-cols-2 gap-x-3 gap-y-7 sm:flex-row"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-7 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} /> <FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<div></div>
<OidcCallbackUrlInput <OidcCallbackUrlInput
label="Callback URLs"
class="w-full" class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value} bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error} bind:error={$inputs.callbackURLs.error}
/> />
<OidcCallbackUrlInput
label="Logout Callback URLs"
class="w-full"
allowEmpty
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
bind:error={$inputs.logoutCallbackURLs.error}
/>
<CheckboxWithLabel <CheckboxWithLabel
id="public-client" id="public-client"
label="Public Client" label="Public Client"
@@ -104,7 +115,7 @@
<Label for="logo">Logo</Label> <Label for="logo">Logo</Label>
<div class="mt-2 flex items-end gap-3"> <div class="mt-2 flex items-end gap-3">
{#if logoDataURL} {#if logoDataURL}
<div class="h-32 w-32 rounded-2xl bg-muted p-3"> <div class="bg-muted h-32 w-32 rounded-2xl p-3">
<img <img
class="m-auto max-h-full max-w-full object-contain" class="m-auto max-h-full max-w-full object-contain"
src={logoDataURL} src={logoDataURL}

View File

@@ -11,10 +11,20 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './client-secret.svelte'; import OneTimeLinkModal from './client-secret.svelte';
let { clients: initialClients }: { clients: Paginated<OidcClient> } = $props(); let {
clients: initialClients
}: {
clients: Paginated<OidcClient>;
} = $props();
let clients = $state<Paginated<OidcClient>>(initialClients); let clients = $state<Paginated<OidcClient>>(initialClients);
let oneTimeLink = $state<string | null>(null); let oneTimeLink = $state<string | null>(null);
let requestOptions: SearchPaginationSortRequest | undefined = $state(); let requestOptions: SearchPaginationSortRequest | undefined = $state({
sort: { column: 'name', direction: 'asc' },
pagination: {
page: initialClients.pagination.currentPage,
limit: initialClients.pagination.itemsPerPage
}
});
$effect(() => { $effect(() => {
clients = initialClients; clients = initialClients;
@@ -46,6 +56,7 @@
<AdvancedTable <AdvancedTable
items={clients} items={clients}
{requestOptions} {requestOptions}
defaultSort={{ column: 'name', direction: 'asc' }}
onRefresh={async (o) => (clients = await oidcService.listClients(o))} onRefresh={async (o) => (clients = await oidcService.listClients(o))}
columns={[ columns={[
{ label: 'Logo' }, { label: 'Logo' },

View File

@@ -1,9 +1,23 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import UserGroupService from '$lib/services/user-group-service'; import UserGroupService from '$lib/services/user-group-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const userGroupService = new UserGroupService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const userGroupService = new UserGroupService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const userGroups = await userGroupService.list();
// Create request options with default sorting
const requestOptions: SearchPaginationSortRequest = {
sort: {
column: 'friendlyName',
direction: 'asc'
},
pagination: {
page: 1,
limit: 10
}
};
const userGroups = await userGroupService.list(requestOptions);
return userGroups; return userGroups;
}; };

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte'; import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/custom-claims-input.svelte'; import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type'; import type { UserGroupCreate } from '$lib/types/user-group.type';

View File

@@ -18,7 +18,13 @@
$props(); $props();
let userGroups = $state<Paginated<UserGroupWithUserCount>>(initialUserGroups); let userGroups = $state<Paginated<UserGroupWithUserCount>>(initialUserGroups);
let requestOptions: SearchPaginationSortRequest | undefined = $state(); let requestOptions: SearchPaginationSortRequest | undefined = $state({
sort: { column: 'friendlyName', direction: 'asc' },
pagination: {
page: initialUserGroups.pagination.currentPage,
limit: initialUserGroups.pagination.itemsPerPage
}
});
const userGroupService = new UserGroupService(); const userGroupService = new UserGroupService();
@@ -47,6 +53,7 @@
items={userGroups} items={userGroups}
onRefresh={async (o) => (userGroups = await userGroupService.list(o))} onRefresh={async (o) => (userGroups = await userGroupService.list(o))}
{requestOptions} {requestOptions}
defaultSort={{ column: 'friendlyName', direction: 'asc' }}
columns={[ columns={[
{ label: 'Friendly Name', sortColumn: 'friendlyName' }, { label: 'Friendly Name', sortColumn: 'friendlyName' },
{ label: 'Name', sortColumn: 'name' }, { label: 'Name', sortColumn: 'name' },

View File

@@ -2,7 +2,7 @@
import AdvancedTable from '$lib/components/advanced-table.svelte'; import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { Paginated } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type'; import type { User } from '$lib/types/user.type';
let { let {
@@ -10,15 +10,24 @@
selectionDisabled = false, selectionDisabled = false,
selectedUserIds = $bindable() selectedUserIds = $bindable()
}: { users: Paginated<User>; selectionDisabled?: boolean; selectedUserIds: string[] } = $props(); }: { users: Paginated<User>; selectionDisabled?: boolean; selectedUserIds: string[] } = $props();
let requestOptions: SearchPaginationSortRequest | undefined = $state({
sort: { column: 'friendlyName', direction: 'asc' },
pagination: {
page: initialUsers.pagination.currentPage,
limit: initialUsers.pagination.itemsPerPage
}
});
let users = $state<Paginated<User>>(initialUsers);
const userService = new UserService(); const userService = new UserService();
let users = $state(initialUsers);
</script> </script>
<AdvancedTable <AdvancedTable
items={users} items={users}
onRefresh={async (o) => (users = await userService.list(o))} onRefresh={async (o) => (users = await userService.list(o))}
{requestOptions}
defaultSort={{ column: 'name', direction: 'asc' }}
columns={[ columns={[
{ label: 'Name', sortColumn: 'name' }, { label: 'Name', sortColumn: 'name' },
{ label: 'Email', sortColumn: 'email' } { label: 'Email', sortColumn: 'email' }

View File

@@ -1,9 +1,23 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const users = await userService.list();
// Create request options with default sorting
const requestOptions: SearchPaginationSortRequest = {
sort: {
column: 'firstName',
direction: 'asc'
},
pagination: {
page: 1,
limit: 10
}
};
const users = await userService.list(requestOptions);
return users; return users;
}; };

View File

@@ -5,5 +5,8 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => { export const load: PageServerLoad = async ({ params, cookies }) => {
const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const user = await userService.get(params.id); const user = await userService.get(params.id);
return user;
return {
user
};
}; };

View File

@@ -1,22 +1,39 @@
<script lang="ts"> <script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte'; import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte'; import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import CustomClaimService from '$lib/services/custom-claim-service'; import CustomClaimService from '$lib/services/custom-claim-service';
import UserGroupService from '$lib/services/user-group-service';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from 'lucide-svelte'; import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import CustomClaimsInput from '../../../../../lib/components/custom-claims-input.svelte'; import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import UserForm from '../user-form.svelte'; import UserForm from '../user-form.svelte';
let { data } = $props(); let { data } = $props();
let user = $state(data); let user = $state({
...data.user,
userGroupIds: data.user.userGroups.map((g) => g.id)
});
const userService = new UserService(); const userService = new UserService();
const customClaimService = new CustomClaimService(); const customClaimService = new CustomClaimService();
const userGroupService = new UserGroupService();
async function updateUserGroups(userIds: string[]) {
await userService
.updateUserGroups(user.id, userIds)
.then(() => toast.success('User groups updated successfully'))
.catch((e) => {
axiosErrorToast(e);
});
}
async function updateUser(updatedUser: UserCreate) { async function updateUser(updatedUser: UserCreate) {
let success = true; let success = true;
@@ -39,6 +56,13 @@
axiosErrorToast(e); axiosErrorToast(e);
}); });
} }
async function updateProfilePicture(image: File) {
await userService
.updateProfilePicture(user.id, image)
.then(() => toast.success('Profile picture updated successfully'))
.catch(axiosErrorToast);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -62,6 +86,38 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<Card.Root>
<Card.Content class="pt-6">
<ProfilePictureSettings
userId={user.id}
isLdapUser={!!user.ldapId}
callback={updateProfilePicture}
/>
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-groups"
title="User Groups"
description="Manage which groups this user belongs to."
>
{#await userGroupService.list() then groups}
<UserGroupSelection
{groups}
bind:selectedGroupIds={user.userGroupIds}
selectionDisabled={!!user.ldapId && $appConfigStore.ldapEnabled}
/>
{/await}
<div class="mt-5 flex justify-end">
<Button
on:click={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">Save</Button
>
</div>
</CollapsibleCard>
<CollapsibleCard <CollapsibleCard
id="user-custom-claims" id="user-custom-claims"
title="Custom Claims" title="Custom Claims"
@@ -69,6 +125,6 @@
> >
<CustomClaimsInput bind:customClaims={user.customClaims} /> <CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button> <Button on:click={updateCustomClaims} type="submit">Save</Button>
</div> </div>
</CollapsibleCard> </CollapsibleCard>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte'; import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';

View File

@@ -17,10 +17,17 @@
import OneTimeLinkModal from './one-time-link-modal.svelte'; import OneTimeLinkModal from './one-time-link-modal.svelte';
let { users = $bindable() }: { users: Paginated<User> } = $props(); let { users = $bindable() }: { users: Paginated<User> } = $props();
let requestOptions: SearchPaginationSortRequest | undefined = $state();
let userIdToCreateOneTimeLink: string | null = $state(null); let userIdToCreateOneTimeLink: string | null = $state(null);
let requestOptions: SearchPaginationSortRequest | undefined = $state({
sort: { column: 'firstName', direction: 'asc' },
pagination: {
page: users.pagination.currentPage,
limit: users.pagination.itemsPerPage
}
});
const userService = new UserService(); const userService = new UserService();
async function deleteUser(user: User) { async function deleteUser(user: User) {
@@ -47,6 +54,7 @@
<AdvancedTable <AdvancedTable
items={users} items={users}
{requestOptions} {requestOptions}
defaultSort={{ column: 'firstName', direction: 'asc' }}
onRefresh={async (options) => (users = await userService.list(options))} onRefresh={async (options) => (users = await userService.list(options))}
columns={[ columns={[
{ label: 'First name', sortColumn: 'firstName' }, { label: 'First name', sortColumn: 'firstName' },

View File

@@ -25,7 +25,8 @@ export const oidcClients = {
nextcloud: { nextcloud: {
id: '3654a746-35d4-4321-ac61-0bdcff2b4055', id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
name: 'Nextcloud', name: 'Nextcloud',
callbackUrl: 'http://nextcloud/auth/callback' callbackUrl: 'http://nextcloud/auth/callback',
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback'
}, },
immich: { immich: {
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018', id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',

View File

@@ -37,7 +37,7 @@ test('Edit OIDC client', async ({ page }) => {
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
await page.getByLabel('Name').fill('Nextcloud updated'); await page.getByLabel('Name').fill('Nextcloud updated');
await page.getByTestId('callback-url-1').fill('http://nextcloud-updated/auth/callback'); await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png'); await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();

View File

@@ -89,10 +89,11 @@ test('Authorize new client fails with user group not allowed', async ({ page })
await page.getByRole('button', { name: 'Sign in' }).click(); await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('paragraph').first()).toHaveText("You're not allowed to access this service."); await expect(page.getByRole('paragraph').first()).toHaveText(
"You're not allowed to access this service."
);
}); });
function createUrlParams(oidcClient: { id: string; callbackUrl: string }) { function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
return new URLSearchParams({ return new URLSearchParams({
client_id: oidcClient.id, client_id: oidcClient.id,
@@ -103,3 +104,33 @@ function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
nonce: 'P1gN3PtpKHJgKUVcLpLjm' nonce: 'P1gN3PtpKHJgKUVcLpLjm'
}); });
} }
test('End session without id token hint shows confirmation page', async ({ page }) => {
await page.goto('/api/oidc/end-session');
await expect(page).toHaveURL('/logout');
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page).toHaveURL('/login');
});
test('End session with id token hint redirects to callback URL', async ({ page }) => {
const client = oidcClients.nextcloud;
const idToken =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.ruYCyjA2BNjROpmLGPNHrhgUNLnpJMEuncvjDYVuv1dAZwvOPfG-Rn-OseAgJDJbV7wJ0qf6ZmBkGWiifwc_B9h--fgd4Vby9fefj0MiHbSDgQyaU5UmpvJU8OlvM-TueD6ICJL0NeT3DwoW5xpIWaHtt3JqJIdP__Q-lTONL2Zokq50kWm0IO-bIw2QrQviSfHNpv8A5rk1RTzpXCPXYNB-eJbm3oBqYQWzerD9HaNrSvrKA7mKG8Te1mI9aMirPpG9FvcAU-I3lY8ky1hJZDu42jHpVEUdWPAmUZPZafoX8iYtlPfkoklDnHj_cdg4aZBGN5bfjM6xf1Oe_rLDWg';
let redirectedCorrectly = false;
await page
.goto(
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}`
)
.catch((e) => {
if (e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
redirectedCorrectly = true;
} else {
throw e;
}
});
expect(redirectedCorrectly).toBeTruthy();
});

View File

@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test'; import test, { expect } from '@playwright/test';
import { users } from './data'; import { users, userGroups } from './data';
import { cleanupBackend } from './utils/cleanup.util'; import { cleanupBackend } from './utils/cleanup.util';
test.beforeEach(cleanupBackend); test.beforeEach(cleanupBackend);
@@ -142,7 +142,7 @@ test('Update user fails with already taken username', async ({ page }) => {
test('Update user custom claims', async ({ page }) => { test('Update user custom claims', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`); await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.getByRole('button', { name: 'Expand card' }).click(); await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
// Add two custom claims // Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click(); await page.getByRole('button', { name: 'Add custom claim' }).click();
@@ -178,3 +178,63 @@ test('Update user custom claims', async ({ page }) => {
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2'); await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value'); await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
}); });
test('Update user group assignments', async ({ page }) => {
const user = users.craig;
await page.goto(`/settings/admin/users/${user.id}`);
// Increase the test timeout since this test is complex
test.setTimeout(30000);
// Expand the user groups section if it's collapsed
const expandButton = page.getByRole('button', { name: 'Expand card' }).first();
if (await expandButton.isVisible()) {
await expandButton.click();
}
// Wait for the user groups table to load
await page.waitForSelector('table');
// First, ensure we start with a clean state - uncheck any checked boxes
const developersCheckbox = page
.getByRole('row', { name: userGroups.developers.name })
.getByRole('checkbox');
const designersCheckbox = page
.getByRole('row', { name: userGroups.designers.name })
.getByRole('checkbox');
// Force click if needed to overcome element interception issues
if ((await developersCheckbox.getAttribute('data-state')) === 'checked') {
await developersCheckbox.click({ force: true });
}
if ((await designersCheckbox.getAttribute('data-state')) === 'checked') {
await designersCheckbox.click({ force: true });
}
// Save the changes to reset state if needed
await page.getByRole('button', { name: 'Save' }).nth(1).click();
// Wait for toast message to appear and disappear
await expect(page.getByRole('status')).toHaveText('User groups updated successfully');
await page.waitForTimeout(1000); // Wait for any animations or state changes
// Now add both groups (using force: true to avoid interception problems)
await developersCheckbox.click({ force: true });
await designersCheckbox.click({ force: true });
// Save the changes
await page.getByRole('button', { name: 'Save' }).nth(1).click();
// Verify success message
await expect(page.getByRole('status')).toHaveText('User groups updated successfully');
await page.reload();
await expect(
page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox')
).toHaveAttribute('data-state', 'checked', { timeout: 10000 });
await expect(
page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox')
).toHaveAttribute('data-state', 'checked', { timeout: 10000 });
});