Compare commits

...

29 Commits

Author SHA1 Message Date
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
Elias Schneider
2e66211b7f release: 0.32.0 2025-02-13 21:02:20 +01:00
Giovanni
2071d002fc feat: add ability to set custom Geolite DB URL 2025-02-13 21:01:43 +01:00
73 changed files with 1396 additions and 521 deletions

View File

@@ -1 +1 @@
0.31.0 0.35.5

View File

@@ -1,3 +1,84 @@
## [](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)
### Features
* add ability to set custom Geolite DB URL ([2071d00](https://github.com/pocket-id/pocket-id/commit/2071d002fc5c3b5ff7a3fca6a5c99f5517196853))
## [](https://github.com/pocket-id/pocket-id/compare/v0.30.0...v) (2025-02-12) ## [](https://github.com/pocket-id/pocket-id/compare/v0.30.0...v) (2025-02-12)

View File

@@ -4,6 +4,8 @@ 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/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 +19,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 +31,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 +68,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=
@@ -211,6 +217,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 +244,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 +278,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

@@ -41,7 +41,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)

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"
@@ -12,6 +13,7 @@ type DbProvider string
const ( const (
DbProviderSqlite DbProvider = "sqlite" DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres" DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
) )
type EnvConfigSchema struct { type EnvConfigSchema struct {
@@ -25,6 +27,7 @@ type EnvConfigSchema struct {
Host string `env:"HOST"` Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"` UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
} }
@@ -39,6 +42,7 @@ var EnvConfig = &EnvConfigSchema{
Host: "0.0.0.0", Host: "0.0.0.0",
MaxMindLicenseKey: "", MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
UiConfigDisabled: false, UiConfigDisabled: false,
} }
@@ -58,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

@@ -30,6 +30,11 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
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.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)
@@ -142,6 +147,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 {

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

@@ -9,6 +9,7 @@ type PublicOidcClientDto struct {
type OidcClientDto struct { type OidcClientDto struct {
PublicOidcClientDto PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`
} }
@@ -16,6 +17,7 @@ type OidcClientDto struct {
type OidcClientWithAllowedUserGroupsDto struct { type OidcClientWithAllowedUserGroupsDto struct {
PublicOidcClientDto PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"` AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
@@ -24,6 +26,7 @@ type OidcClientWithAllowedUserGroupsDto struct {
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"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`
} }
@@ -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

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

@@ -39,7 +39,8 @@ type OidcClient struct {
Name string `sortable:"true"` Name string `sortable:"true"`
Secret string Secret string
CallbackURLs CallbackURLs CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string ImageType *string
HasLogo bool `gorm:"-"` HasLogo bool `gorm:"-"`
IsPublic bool IsPublic bool
@@ -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

@@ -115,18 +115,22 @@ func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
} }
// Connect to the SMTP server // Connect to the SMTP server
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" { // Connect to the SMTP server based on TLS setting
switch srv.appConfigService.DbConfig.SmtpTls.Value {
case "none":
client, err = srv.connectToSmtpServer(smtpAddress) client, err = srv.connectToSmtpServer(smtpAddress)
} else if port == "465" { case "tls":
client, err = srv.connectToSmtpServerUsingImplicitTLS( client, err = srv.connectToSmtpServerUsingImplicitTLS(
smtpAddress, smtpAddress,
tlsConfig, tlsConfig,
) )
} else { case "starttls":
client, err = srv.connectToSmtpServerUsingStartTLS( 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)

View File

@@ -21,6 +21,7 @@ import (
) )
type GeoLiteService struct { type GeoLiteService struct {
disableUpdater bool
mutex sync.Mutex mutex sync.Mutex
} }
@@ -43,6 +44,12 @@ var tailscaleIPNets = []*net.IPNet{
func NewGeoLiteService() *GeoLiteService { func NewGeoLiteService() *GeoLiteService {
service := &GeoLiteService{} service := &GeoLiteService{}
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
// Warn the user, and disable the updater.
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.")
service.disableUpdater = true
}
go func() { go func() {
if err := service.updateDatabase(); err != nil { if err := service.updateDatabase(); err != nil {
log.Printf("Failed to update GeoLite2 City database: %v\n", err) log.Printf("Failed to update GeoLite2 City database: %v\n", err)
@@ -104,18 +111,19 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days. // UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
func (s *GeoLiteService) updateDatabase() error { func (s *GeoLiteService) updateDatabase() error {
if s.disableUpdater {
// Avoid updating the GeoLite2 City database.
return nil
}
if s.isDatabaseUpToDate() { if s.isDatabaseUpToDate() {
log.Println("GeoLite2 City database is up-to-date.") log.Println("GeoLite2 City database is up-to-date.")
return nil return nil
} }
log.Println("Updating GeoLite2 City database...") log.Println("Updating GeoLite2 City database...")
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
// Download and extract the database
downloadUrl := fmt.Sprintf(
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz",
common.EnvConfig.MaxMindLicenseKey,
)
// Download the database tar.gz file // Download the database tar.gz file
resp, err := http.Get(downloadUrl) resp, err := http.Get(downloadUrl)
if err != nil { if err != 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,7 +132,7 @@ func (s *LdapService) SyncGroups() error {
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute), LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
} }
usersToAddDto = dto.UserGroupUpdateUsersDto{ usersToAddDto := dto.UserGroupUpdateUsersDto{
UserIDs: membersUserId, UserIDs: membersUserId,
} }
@@ -176,6 +191,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 +204,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 +253,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 +272,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 +281,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
} }
@@ -230,6 +230,7 @@ func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string)
client := model.OidcClient{ client := model.OidcClient{
Name: input.Name, Name: input.Name,
CallbackURLs: input.CallbackURLs, CallbackURLs: input.CallbackURLs,
LogoutCallbackURLs: input.LogoutCallbackURLs,
CreatedByID: userID, CreatedByID: userID,
IsPublic: input.IsPublic, IsPublic: input.IsPublic,
PkceEnabled: input.IsPublic || input.PkceEnabled, PkceEnabled: input.IsPublic || input.PkceEnabled,
@@ -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"
@@ -11,8 +12,8 @@ import (
"time" "time"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/pocket-id/pocket-id/backend/resources"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/resources"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -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 {
@@ -114,7 +116,8 @@ func (s *TestService) SeedDatabase() error {
}, },
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"},
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
ImageType: utils.StringPointer("png"), ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID, CreatedByID: users[0].ID,
}, },
@@ -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

@@ -3,17 +3,21 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/utils/image"
"io"
"log" "log"
"net/url" "net/url"
"os"
"strings" "strings"
"time" "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/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"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email" "github.com/pocket-id/pocket-id/backend/internal/utils/email"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -48,6 +52,71 @@ func (s *UserService) GetUser(userID string) (model.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) 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 +128,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
} }

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

@@ -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;
color: #333;
margin: 0; margin: 0;
padding: 0; padding: 0;
} border: 0;
.container { font-size: 100%;
background-color: #fff; font-family: Arial, sans-serif;
line-height: 1.5;
}
body {
background-color: #f0f0f0;
color: #333; color: #333;
padding: 32px; }
border-radius: 10px; .container {
width: 100%;
max-width: 600px; max-width: 600px;
margin: 40px auto; margin: 40px auto;
background-color: #fff;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} padding: 32px;
}
.header { .header {
display: flex; display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px; margin-bottom: 24px;
} }
.header .logo {
display: flex;
align-items: center;
gap: 8px;
}
.header .logo img { .header .logo img {
width: 32px; width: 32px;
height: 32px; height: 32px;
object-fit: cover; vertical-align: middle;
} }
.header h1 { .header h1 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
} display: inline-block;
vertical-align: middle;
margin-left: 8px;
}
.warning { .warning {
background-color: #ffd966; background-color: #ffd966;
color: #7f6000; color: #7f6000;
padding: 4px 12px; padding: 4px 12px;
border-radius: 50px; border-radius: 50px;
font-size: 0.875rem; font-size: 0.875rem;
} margin: auto 0 auto auto;
}
.content { .content {
background-color: #fafafa; background-color: #fafafa;
color: #333;
padding: 24px; padding: 24px;
border-radius: 10px; border-radius: 10px;
} }
.content h2 { .content h2 {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: bold; font-weight: bold;
margin-bottom: 16px; margin-bottom: 16px;
} }
.grid { .grid {
display: grid; width: 100%;
grid-template-columns: 1fr 1fr;
gap: 16px;
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 { .label {
color: #888; color: #888;
font-size: 0.875rem; font-size: 0.875rem;
margin-bottom: 4px; }
}
.message { .message {
font-size: 1rem; font-size: 1rem;
line-height: 1.5; line-height: 1.5;
} margin-top: 16px;
}
.button { .button {
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
background-color: #000000; background-color: #000000;
color: #ffffff; color: #ffffff;
padding: 0.7rem 1.5rem; padding: 0.7rem 1.5rem;
outline: none;
border: none;
text-decoration: none; text-decoration: none;
} border-radius: 4px;
font-size: 1rem;
font-weight: 500;
display: inline-block;
margin-top: 24px;
}
.button-container { .button-container {
text-align: center; 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">
<tr>
{{ if and .Data.City .Data.Country }} {{ if and .Data.City .Data.Country }}
<div> <td>
<p class="label">Approximate Location</p> <p class="label">Approximate Location</p>
<p>{{ .Data.City }}, {{ .Data.Country }}</p> <p>{{ .Data.City }}, {{ .Data.Country }}</p>
</div> </td>
{{ end }} {{ end }}
<div> <td>
<p class="label">IP Address</p> <p class="label">IP Address</p>
<p>{{ .Data.IPAddress }}</p> <p>{{ .Data.IPAddress }}</p>
</div> </td>
<div> </tr>
<tr>
<td>
<p class="label">Device</p> <p class="label">Device</p>
<p>{{ .Data.Device }}</p> <p>{{ .Data.Device }}</p>
</div> </td>
<div> <td>
<p class="label">Sign-In Time</p> <p class="label">Sign-In Time</p>
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p> <p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
</div> </td>
</div> </tr>
</table>
<p class="message"> <p class="message">
This sign-in was detected from a new device or location. If you recognize this activity, you can 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. safely ignore this message. If not, please review your account and security settings.
</p> </p>
</div> </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.31.0", "version": "0.35.5",
"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,9 +27,8 @@
} }
</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>
@@ -37,5 +36,4 @@
<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

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

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

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

@@ -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') {
authorizationRequired = true;
} else {
errorMessage = getWebauthnErrorMessage(e); 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,20 +33,17 @@
</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]"
>
<nav class="grid gap-4 text-sm text-muted-foreground">
{#each links as { href, label }} {#each links as { href, label }}
<a {href} class={$page.url.pathname.startsWith(href) ? 'font-bold text-primary' : ''}> <a {href} class={$page.url.pathname.startsWith(href) ? 'text-primary font-bold' : ''}>
{label} {label}
</a> </a>
{/each} {/each}
@@ -61,13 +58,12 @@
{/if} {/if}
</nav> </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,6 +95,7 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<fieldset disabled={uiConfigDisabled}>
<h4 class="text-lg font-semibold">SMTP Configuration</h4> <h4 class="text-lg font-semibold">SMTP Configuration</h4>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} /> <FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
@@ -93,12 +103,22 @@
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
<CheckboxWithLabel <div class="grid gap-2">
id="tls" <Label class="mb-0" for="smtp-tls">SMTP TLS Option</Label>
label="TLS" <Select.Root
description="Enable TLS for the SMTP connection." selected={{ value: $inputs.smtpTls.value, label: tlsOptions[$inputs.smtpTls.value] }}
bind:checked={$inputs.smtpTls.value} onSelectedChange={(v) => ($inputs.smtpTls.value = v!.value)}
/> >
<Select.Trigger>
<Select.Value placeholder="Email TLS Option" />
</Select.Trigger>
<Select.Content>
<Select.Item value="none" label="None" />
<Select.Item value="starttls" label="StartTLS" />
<Select.Item value="tls" label="TLS" />
</Select.Content>
</Select.Root>
</div>
<CheckboxWithLabel <CheckboxWithLabel
id="skip-cert-verify" id="skip-cert-verify"
label="Skip Certificate Verification" label="Skip Certificate Verification"
@@ -121,11 +141,11 @@
bind:checked={$inputs.emailOneTimeAccessEnabled.value} bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/> />
</div> </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,6 +44,7 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<FormInput label="Application Name" bind:input={$inputs.appName} /> <FormInput label="Application Name" bind:input={$inputs.appName} />
<FormInput <FormInput
@@ -66,4 +69,5 @@
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">Save</Button>
</div> </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,15 +103,24 @@
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<h4 class="text-lg font-semibold">Client Configuration</h4> <h4 class="text-lg font-semibold">Client Configuration</h4>
<fieldset disabled={uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label="LDAP URL" placeholder="ldap://example.com:389" bind:input={$inputs.ldapUrl} /> <FormInput
label="LDAP URL"
placeholder="ldap://example.com:389"
bind:input={$inputs.ldapUrl}
/>
<FormInput <FormInput
label="LDAP Bind DN" label="LDAP Bind DN"
placeholder="cn=people,dc=example,dc=com" placeholder="cn=people,dc=example,dc=com"
bind:input={$inputs.ldapBindDn} bind:input={$inputs.ldapBindDn}
/> />
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} /> <FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} />
<FormInput label="LDAP Base DN" placeholder="dc=example,dc=com" bind:input={$inputs.ldapBase} /> <FormInput
label="LDAP Base DN"
placeholder="dc=example,dc=com"
bind:input={$inputs.ldapBase}
/>
<FormInput <FormInput
label="User Search Filter" label="User Search Filter"
description="The Search filter to use to search/sync users." description="The Search filter to use to search/sync users."
@@ -153,6 +168,18 @@
placeholder="sn" placeholder="sn"
bind:input={$inputs.ldapAttributeUserLastName} bind:input={$inputs.ldapAttributeUserLastName}
/> />
<FormInput
label="User Profile Picture Attribute"
description="The value of this attribute can either be a URL, a binary or a base64 encoded image."
placeholder="jpegPhoto"
bind:input={$inputs.ldapAttributeUserProfilePicture}
/>
<FormInput
label="Group Members Attribute"
description="The attribute to use for querying members of a group."
placeholder="member"
bind:input={$inputs.ldapAttributeGroupMember}
/>
<FormInput <FormInput
label="Group Unique Identifier Attribute" label="Group Unique Identifier Attribute"
description="The value of this attribute should never change." description="The value of this attribute should never change."
@@ -171,14 +198,15 @@
bind:input={$inputs.ldapAttributeAdminGroup} bind:input={$inputs.ldapAttributeAdminGroup}
/> />
</div> </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,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte'; import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -17,6 +16,7 @@
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'; import UserGroupSelection from '../user-group-selection.svelte';
import CollapsibleCard from '$lib/components/collapsible-card.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,6 +129,7 @@
</span> </span>
</CopyToClipboard> </CopyToClipboard>
{:else} {:else}
<div>
<span class="text-muted-foreground text-sm" data-testid="client-secret" <span class="text-muted-foreground text-sm" data-testid="client-secret"
>••••••••••••••••••••••••••••••••</span >••••••••••••••••••••••••••••••••</span
> >
@@ -138,13 +140,14 @@
variant="ghost" variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button 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

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

@@ -1,5 +1,7 @@
<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';
@@ -9,7 +11,6 @@
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 UserForm from '../user-form.svelte'; import UserForm from '../user-form.svelte';
let { data } = $props(); let { data } = $props();
@@ -39,6 +40,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 +70,16 @@
</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 <CollapsibleCard
id="user-custom-claims" id="user-custom-claims"
title="Custom Claims" title="Custom Claims"

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

@@ -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();
});