Compare commits

..

48 Commits

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

View File

@@ -1 +1,5 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links:
- name: 💬 Discord
url: https://discord.gg/8wudU9KaxM
about: For help and chatting with the community

View File

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

View File

@@ -1 +1 @@
0.31.0 0.37.0

View File

@@ -1,3 +1,119 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.36.0...v) (2025-03-10)
### Features
* **account:** add ability to sign in with login code ([#271](https://github.com/pocket-id/pocket-id/issues/271)) ([eb1426e](https://github.com/pocket-id/pocket-id/commit/eb1426ed2684b5ddd185db247a8e082b28dfd014))
* increase default item count per page ([a9713cf](https://github.com/pocket-id/pocket-id/commit/a9713cf6a1e3c879dc773889b7983e51bbe3c45b))
### Bug Fixes
* add back setup page ([6a8dd84](https://github.com/pocket-id/pocket-id/commit/6a8dd84ca9396ff3369385af22f7e1f081bec2b2))
* add timeout to update check ([04efc36](https://github.com/pocket-id/pocket-id/commit/04efc3611568a0b0127b542b8cc252d9e783af46))
* make sorting consistent around tables ([8e344f1](https://github.com/pocket-id/pocket-id/commit/8e344f1151628581b637692a1de0e48e7235a22d))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.6...v) (2025-03-06)
### Features
* display groups on the account page ([#296](https://github.com/pocket-id/pocket-id/issues/296)) ([0f14a93](https://github.com/pocket-id/pocket-id/commit/0f14a93e1d6a723b0994ba475b04702646f04464))
* enable sd_notify support ([#277](https://github.com/pocket-id/pocket-id/issues/277)) ([91f254c](https://github.com/pocket-id/pocket-id/commit/91f254c7bb067646c42424c5c62ebcd90a0c8792))
### Bug Fixes
* default sorting on tables ([#299](https://github.com/pocket-id/pocket-id/issues/299)) ([ff34e3b](https://github.com/pocket-id/pocket-id/commit/ff34e3b925321c80e9d7d42d0fd50e397d198435))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.5...v) (2025-03-03)
### Bug Fixes
* support `LOGIN` authentication method for SMTP ([#292](https://github.com/pocket-id/pocket-id/issues/292)) ([2d733fc](https://github.com/pocket-id/pocket-id/commit/2d733fc79faefca23d54b22768029c3ba3427410))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.4...v) (2025-03-03)
### Bug Fixes
* profile picture orientation if image is rotated with EXIF ([1026ee4](https://github.com/pocket-id/pocket-id/commit/1026ee4f5b5c7fda78b65c94a5d0f899525defd1))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.3...v) (2025-03-01)
### Bug Fixes
* add `groups` scope and claim to well known endpoint ([4bafee4](https://github.com/pocket-id/pocket-id/commit/4bafee4f58f5a76898cf66d6192916d405eea389))
* profile picture of other user can't be updated ([#273](https://github.com/pocket-id/pocket-id/issues/273)) ([ef25f6b](https://github.com/pocket-id/pocket-id/commit/ef25f6b6b84b52f1310d366d40aa3769a6fe9bef))
* support POST for OIDC userinfo endpoint ([1652cc6](https://github.com/pocket-id/pocket-id/commit/1652cc65f3f966d018d81a1ae22abb5ff1b4c47b))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.2...v) (2025-02-25)
### Bug Fixes
* add option to manually select SMTP TLS method ([#268](https://github.com/pocket-id/pocket-id/issues/268)) ([01a9de0](https://github.com/pocket-id/pocket-id/commit/01a9de0b04512c62d0f223de33d711f93c49b9cc))
* **ldap:** sync error if LDAP user collides with an existing user ([fde951b](https://github.com/pocket-id/pocket-id/commit/fde951b543281fedf9f602abae26b50881e3d157))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.1...v) (2025-02-24)
### Bug Fixes
* delete profile picture if user gets deleted ([9a167d4](https://github.com/pocket-id/pocket-id/commit/9a167d4076872e5e3e5d78d2a66ef7203ca5261b))
* updating profile picture of other user updates own profile picture ([887c5e4](https://github.com/pocket-id/pocket-id/commit/887c5e462a50c8fb579ca6804f1a643d8af78fe8))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.0...v) (2025-02-22)
### Bug Fixes
* add validation that `PUBLIC_APP_URL` can't contain a path ([a6ae7ae](https://github.com/pocket-id/pocket-id/commit/a6ae7ae28713f7fc8018ae2aa7572986df3e1a5b))
* binary profile picture can't be imported from LDAP ([840a672](https://github.com/pocket-id/pocket-id/commit/840a672fc35ca8476caf86d7efaba9d54bce86aa))
## [](https://github.com/pocket-id/pocket-id/compare/v0.34.0...v) (2025-02-19)
### Features
* add ability to upload a profile picture ([#244](https://github.com/pocket-id/pocket-id/issues/244)) ([652ee6a](https://github.com/pocket-id/pocket-id/commit/652ee6ad5d6c46f0d35c955ff7bb9bdac6240ca6))
### Bug Fixes
* app config strings starting with a number are parsed incorrectly ([816c198](https://github.com/pocket-id/pocket-id/commit/816c198a42c189cb1f2d94885d2e3623e47e2848))
* emails do not get rendered correctly in Gmail ([dca9e7a](https://github.com/pocket-id/pocket-id/commit/dca9e7a11a3ba5d3b43a937f11cb9d16abad2db5))
## [](https://github.com/pocket-id/pocket-id/compare/v0.33.0...v) (2025-02-16)
### Features
* add LDAP group membership attribute ([#236](https://github.com/pocket-id/pocket-id/issues/236)) ([39b46e9](https://github.com/pocket-id/pocket-id/commit/39b46e99a9b930ea39cf640c3080530cfff5be6e))
## [](https://github.com/pocket-id/pocket-id/compare/v0.32.0...v) (2025-02-14)
### Features
* add end session endpoint ([#232](https://github.com/pocket-id/pocket-id/issues/232)) ([7550333](https://github.com/pocket-id/pocket-id/commit/7550333fe2ff6424f3168f63c5179d76767532fd))
### Bug Fixes
* alignment of OIDC client details ([c3980d3](https://github.com/pocket-id/pocket-id/commit/c3980d3d28a7158a4dc9369af41f185b891e485e))
* layout of OIDC client details page on mobile ([3de1301](https://github.com/pocket-id/pocket-id/commit/3de1301fa84b3ab4fff4242d827c7794d44910f2))
* show "Sync Now" and "Test Email" button even if UI config is disabled ([4d0fff8](https://github.com/pocket-id/pocket-id/commit/4d0fff821e2245050ce631b4465969510466dfae))
## [](https://github.com/pocket-id/pocket-id/compare/v0.31.0...v) (2025-02-13)
### 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,10 @@ go 1.23.1
require ( require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.7.0 github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.15.0 github.com/go-co-op/gocron/v2 v2.15.0
@@ -17,6 +21,7 @@ require (
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
golang.org/x/crypto v0.32.0 golang.org/x/crypto v0.32.0
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0 golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11 gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.7 gorm.io/driver/sqlite v1.5.7
@@ -28,6 +33,7 @@ require (
github.com/bytedance/sonic v1.12.8 // indirect github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.5 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
@@ -64,9 +70,9 @@ require (
golang.org/x/arch v0.13.0 // indirect golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.10.0 // indirect golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package common
import ( import (
"log" "log"
"net/url"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
@@ -10,8 +11,9 @@ import (
type DbProvider string 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,40 @@ 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{}
type OneTimeAccessDisabledError struct{}
func (e *OneTimeAccessDisabledError) Error() string {
return "One-time access is disabled"
}
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }

View File

@@ -27,7 +27,7 @@ func NewAppConfigController(
} }
group.GET("/application-configuration", acc.listAppConfigHandler) group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler) group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", acc.updateAppConfigHandler) group.PUT("/application-configuration", jwtAuthMiddleware.Add(true), acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler) group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler) group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)

View File

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

View File

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

View File

@@ -27,10 +27,19 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler) group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler) group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler) group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler)
group.GET("/users/:id/groups", jwtAuthMiddleware.Add(true), uc.getUserGroupsHandler)
group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler) group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler)
group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler) group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler)
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler) group.PUT("/users/:id/user-groups", jwtAuthMiddleware.Add(true), uc.updateUserGroups)
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
group.GET("/users/me/profile-picture.png", jwtAuthMiddleware.Add(false), uc.getCurrentUserProfilePictureHandler)
group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler)
group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateCurrentUserProfilePictureHandler)
group.POST("/users/me/one-time-access-token", jwtAuthMiddleware.Add(false), uc.createOwnOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createAdminOneTimeAccessTokenHandler)
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)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler) group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
@@ -41,6 +50,23 @@ type UserController struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
} }
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id")
groups, err := uc.userService.GetUserGroups(userID)
if err != nil {
c.Error(err)
return
}
var groupsDto []dto.UserGroupDtoWithUsers
if err := dto.MapStructList(groups, &groupsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, groupsDto)
}
func (uc *UserController) listUsersHandler(c *gin.Context) { func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest var sortedPaginationRequest utils.SortedPaginationRequest
@@ -142,13 +168,84 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
uc.updateUser(c, true) uc.updateUser(c, true)
} }
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) { 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, own bool) {
var input dto.OneTimeAccessTokenCreateDto var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) c.Error(err)
return return
} }
if own {
input.UserID = c.GetString("userID")
}
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt) token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
@@ -158,6 +255,14 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"token": token}) c.JSON(http.StatusCreated, gin.H{"token": token})
} }
func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
uc.createOneTimeAccessTokenHandler(c, true)
}
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
uc.createOneTimeAccessTokenHandler(c, false)
}
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) { func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto var input dto.OneTimeAccessEmailDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
@@ -242,3 +347,25 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
} }
func (uc *UserController) updateUserGroups(c *gin.Context) {
var input dto.UserUpdateUserGroupDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{ var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
Path: "one-time-access", Path: "one-time-access",
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string { Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
return "One time access" return "Login Code"
}, },
} }
@@ -51,7 +51,9 @@ type NewLoginTemplateData struct {
} }
type OneTimeAccessTemplateData = struct { type OneTimeAccessTemplateData = struct {
Link string Code string
LoginLink string
LoginLinkWithCode string
} }
// this is list of all template paths used for preloading templates // this is list of all template paths used for preloading templates

View File

@@ -21,7 +21,8 @@ import (
) )
type GeoLiteService struct { type GeoLiteService struct {
mutex sync.Mutex disableUpdater bool
mutex sync.Mutex
} }
var localhostIPNets = []*net.IPNet{ var localhostIPNets = []*net.IPNet{
@@ -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,22 +132,18 @@ func (s *LdapService) SyncGroups() error {
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute), LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
} }
usersToAddDto = dto.UserGroupUpdateUsersDto{
UserIDs: membersUserId,
}
if databaseGroup.ID == "" { if databaseGroup.ID == "" {
newGroup, err := s.groupService.Create(syncGroup) newGroup, err := s.groupService.Create(syncGroup)
if err != nil { if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
} else { } else {
if _, err = s.groupService.UpdateUsers(newGroup.ID, usersToAddDto); err != nil { if _, err = s.groupService.UpdateUsers(newGroup.ID, membersUserId); err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
} }
} }
} else { } else {
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true) _, err = s.groupService.Update(databaseGroup.ID, syncGroup, true)
_, err = s.groupService.UpdateUsers(databaseGroup.ID, usersToAddDto) _, err = s.groupService.UpdateUsers(databaseGroup.ID, membersUserId)
if err != nil { if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
return err return err
@@ -176,6 +187,7 @@ func (s *LdapService) SyncUsers() error {
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
profilePictureAttribute := s.appConfigService.DbConfig.LdapAttributeUserProfilePicture.Value
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value
@@ -188,6 +200,7 @@ func (s *LdapService) SyncUsers() error {
emailAttribute, emailAttribute,
firstNameAttribute, firstNameAttribute,
lastNameAttribute, lastNameAttribute,
profilePictureAttribute,
} }
// Filters must start and finish with ()! // Filters must start and finish with ()!
@@ -236,9 +249,14 @@ func (s *LdapService) SyncUsers() error {
if err != nil { if err != nil {
log.Printf("Error syncing user %s: %s", newUser.Username, err) log.Printf("Error syncing user %s: %s", newUser.Username, err)
} }
} }
// Save profile picture
if pictureString := value.GetAttributeValue(profilePictureAttribute); pictureString != "" {
if err := s.SaveProfilePicture(databaseUser.ID, pictureString); err != nil {
log.Printf("Error saving profile picture for user %s: %s", newUser.Username, err)
}
}
} }
// Get all LDAP users from the database // Get all LDAP users from the database
@@ -250,7 +268,7 @@ func (s *LdapService) SyncUsers() error {
// Delete users that no longer exist in LDAP // Delete users that no longer exist in LDAP
for _, user := range ldapUsersInDb { for _, user := range ldapUsersInDb {
if _, exists := ldapUserIDs[*user.LdapID]; !exists { if _, exists := ldapUserIDs[*user.LdapID]; !exists {
if err := s.db.Delete(&model.User{}, "ldap_id = ?", user.LdapID).Error; err != nil { if err := s.userService.DeleteUser(user.ID); err != nil {
log.Printf("Failed to delete user %s with: %v", user.Username, err) log.Printf("Failed to delete user %s with: %v", user.Username, err)
} else { } else {
log.Printf("Deleted user %s", user.Username) log.Printf("Deleted user %s", user.Username)
@@ -259,3 +277,33 @@ func (s *LdapService) SyncUsers() error {
} }
return nil return nil
} }
func (s *LdapService) SaveProfilePicture(userId string, pictureString string) error {
var reader io.Reader
if _, err := url.ParseRequestURI(pictureString); err == nil {
// If the photo is a URL, download it
response, err := http.Get(pictureString)
if err != nil {
return fmt.Errorf("failed to download profile picture: %w", err)
}
defer response.Body.Close()
reader = response.Body
} else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil {
// If the photo is a base64 encoded string, decode it
reader = bytes.NewReader(decodedPhoto)
} else {
// If the photo is a string, we assume that it's a binary string
reader = bytes.NewReader([]byte(pictureString))
}
// Update the profile picture
if err := s.userService.UpdateProfilePicture(userId, reader); err != nil {
return fmt.Errorf("failed to update profile picture: %w", err)
}
return nil
}

View File

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

View File

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

View File

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

View File

@@ -3,17 +3,22 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/url" "net/url"
"os"
"strings" "strings"
"time" "time"
"github.com/google/uuid"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
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"
) )
@@ -44,10 +49,83 @@ func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils
func (s *UserService) GetUser(userID string) (model.User, error) { func (s *UserService) GetUser(userID string) (model.User, error) {
var user model.User var user model.User
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error err := s.db.Preload("UserGroups").Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
return user, err return user, err
} }
func (s *UserService) GetProfilePicture(userID string) (io.Reader, int64, error) {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return nil, 0, &common.InvalidUUIDError{}
}
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
file, err := os.Open(profilePicturePath)
if err == nil {
// Get the file size
fileInfo, err := file.Stat()
if err != nil {
return nil, 0, err
}
return file, fileInfo.Size(), nil
}
// If the file does not exist, return the default profile picture
user, err := s.GetUser(userID)
if err != nil {
return nil, 0, err
}
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.FirstName, user.LastName)
if err != nil {
return nil, 0, err
}
return defaultPicture, int64(defaultPicture.Len()), nil
}
func (s *UserService) GetUserGroups(userID string) ([]model.UserGroup, error) {
var user model.User
if err := s.db.Preload("UserGroups").Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return user.UserGroups, nil
}
func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return &common.InvalidUUIDError{}
}
// Convert the image to a smaller square image
profilePicture, err := profilepicture.CreateProfilePicture(file)
if err != nil {
return err
}
// Ensure the directory exists
profilePictureDir := fmt.Sprintf("%s/profile-pictures", common.EnvConfig.UploadPath)
if err := os.MkdirAll(profilePictureDir, os.ModePerm); err != nil {
return err
}
// Create the profile picture file
createdProfilePicture, err := os.Create(fmt.Sprintf("%s/%s.png", profilePictureDir, userID))
if err != nil {
return err
}
defer createdProfilePicture.Close()
// Copy the image to the file
_, err = io.Copy(createdProfilePicture, profilePicture)
if err != nil {
return err
}
return nil
}
func (s *UserService) DeleteUser(userID string) error { func (s *UserService) DeleteUser(userID string) error {
var user model.User var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil { if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
@@ -59,6 +137,12 @@ func (s *UserService) DeleteUser(userID string) error {
return &common.LdapUserUpdateError{} return &common.LdapUserUpdateError{}
} }
// Delete the profile picture
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
if err := os.Remove(profilePicturePath); err != nil && !os.IsNotExist(err) {
return err
}
return s.db.Delete(&user).Error return s.db.Delete(&user).Error
} }
@@ -113,6 +197,11 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
} }
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error { func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
isDisabled := s.appConfigService.DbConfig.EmailOneTimeAccessEnabled.Value != "true"
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
var user model.User var user model.User
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil { if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
// Do not return error if user not found to prevent email enumeration // Do not return error if user not found to prevent email enumeration
@@ -123,17 +212,18 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
} }
} }
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour)) oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(15*time.Minute))
if err != nil { if err != nil {
return err return err
} }
link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken) link := fmt.Sprintf("%s/lc", common.EnvConfig.AppURL)
linkWithCode := fmt.Sprintf("%s/%s", link, oneTimeAccessToken)
// Add redirect path to the link // Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") { if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath) encodedRedirectPath := url.QueryEscape(redirectPath)
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath) linkWithCode = fmt.Sprintf("%s?redirect=%s", linkWithCode, encodedRedirectPath)
} }
go func() { go func() {
@@ -141,7 +231,9 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
Name: user.Username, Name: user.Username,
Email: user.Email, Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Link: link, Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
}) })
if err != nil { if err != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err) log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
@@ -152,7 +244,14 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
} }
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) { func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16) tokenLength := 16
// If expires at is less than 15 minutes, use an 6 character token instead of 16
if expiresAt.Sub(time.Now()) <= 15*time.Minute {
tokenLength = 6
}
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -194,6 +293,33 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAg
return oneTimeAccessToken.User, accessToken, nil return oneTimeAccessToken.User, accessToken, nil
} }
func (s *UserService) UpdateUserGroups(id string, userGroupIds []string) (user model.User, err error) {
user, err = s.GetUser(id)
if err != nil {
return model.User{}, err
}
// Fetch the groups based on userGroupIds
var groups []model.UserGroup
if len(userGroupIds) > 0 {
if err := s.db.Where("id IN (?)", userGroupIds).Find(&groups).Error; err != nil {
return model.User{}, err
}
}
// Replace the current groups with the new set of groups
if err := s.db.Model(&user).Association("UserGroups").Replace(groups); err != nil {
return model.User{}, err
}
// Save the updated user
if err := s.db.Save(&user).Error; err != nil {
return model.User{}, err
}
return user, nil
}
func (s *UserService) SetupInitialAdmin() (model.User, string, error) { func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
var userCount int64 var userCount int64
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil { if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {

View File

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

View File

@@ -47,7 +47,7 @@ func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (Pagin
} }
if pageSize < 1 { if pageSize < 1 {
pageSize = 10 pageSize = 20
} else if pageSize > 100 { } else if pageSize > 100 {
pageSize = 100 pageSize = 100
} }

View File

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

View File

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

View File

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

View File

@@ -6,12 +6,12 @@
</div> </div>
</div> </div>
<div class="content"> <div class="content">
<h2>One-Time Access</h2> <h2>Login Code</h2>
<p class="message"> <p class="message">
Click the button below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes. Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in 15 minutes.
</p> </p>
<div class="button-container"> <div class="button-container">
<a class="button" href="{{ .Data.Link }}" class="button">Sign In</a> <a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
</div> </div>
</div> </div>
{{ end -}} {{ end -}}

View File

@@ -1,8 +1,10 @@
{{ define "base" -}} {{ define "base" -}}
One-Time Access Login Code
==================== ====================
Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes. Click the link below to sign in to {{ .AppName }} with a login code. This code expires in 15 minutes.
{{ .Data.Link }} {{ .Data.LoginLinkWithCode }}
Or visit {{ .Data.LoginLink }} and enter the the code "{{ .Data.Code }}".
{{ 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,16 +1,16 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.30.0", "version": "0.36.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.30.0", "version": "0.36.0",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.7.9", "axios": "^1.8.2",
"bits-ui": "^0.22.0", "bits-ui": "^0.22.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
@@ -45,7 +45,7 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.21.0", "typescript-eslint": "^8.21.0",
"vite": "^6.0.11" "vite": "^6.2.1"
} }
}, },
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
@@ -88,12 +88,13 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"aix" "aix"
@@ -103,12 +104,13 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -118,12 +120,13 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -133,12 +136,13 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -148,12 +152,13 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -163,12 +168,13 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -178,12 +184,13 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -193,12 +200,13 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -208,12 +216,13 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -223,12 +232,13 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -238,12 +248,13 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -253,12 +264,13 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -268,12 +280,13 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -283,12 +296,13 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -298,12 +312,13 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -313,12 +328,13 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -328,12 +344,13 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -343,12 +360,13 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -358,12 +376,13 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -373,12 +392,13 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -388,12 +408,13 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -403,12 +424,13 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"sunos" "sunos"
@@ -418,12 +440,13 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -433,12 +456,13 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -448,12 +472,13 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -1888,9 +1913,10 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.9", "version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@@ -2219,10 +2245,11 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.24.2", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -2230,31 +2257,31 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2", "@esbuild/aix-ppc64": "0.25.1",
"@esbuild/android-arm": "0.24.2", "@esbuild/android-arm": "0.25.1",
"@esbuild/android-arm64": "0.24.2", "@esbuild/android-arm64": "0.25.1",
"@esbuild/android-x64": "0.24.2", "@esbuild/android-x64": "0.25.1",
"@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-arm64": "0.25.1",
"@esbuild/darwin-x64": "0.24.2", "@esbuild/darwin-x64": "0.25.1",
"@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-arm64": "0.25.1",
"@esbuild/freebsd-x64": "0.24.2", "@esbuild/freebsd-x64": "0.25.1",
"@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm": "0.25.1",
"@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-arm64": "0.25.1",
"@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-ia32": "0.25.1",
"@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-loong64": "0.25.1",
"@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-mips64el": "0.25.1",
"@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-ppc64": "0.25.1",
"@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-riscv64": "0.25.1",
"@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-s390x": "0.25.1",
"@esbuild/linux-x64": "0.24.2", "@esbuild/linux-x64": "0.25.1",
"@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-arm64": "0.25.1",
"@esbuild/netbsd-x64": "0.24.2", "@esbuild/netbsd-x64": "0.25.1",
"@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-arm64": "0.25.1",
"@esbuild/openbsd-x64": "0.24.2", "@esbuild/openbsd-x64": "0.25.1",
"@esbuild/sunos-x64": "0.24.2", "@esbuild/sunos-x64": "0.25.1",
"@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-arm64": "0.25.1",
"@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-ia32": "0.25.1",
"@esbuild/win32-x64": "0.24.2" "@esbuild/win32-x64": "0.25.1"
} }
}, },
"node_modules/esbuild-runner": { "node_modules/esbuild-runner": {
@@ -3553,9 +3580,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.1", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -3570,6 +3597,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.8", "nanoid": "^3.3.8",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -4557,13 +4585,14 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.0.11", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
"integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", "integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
"license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.24.2", "esbuild": "^0.25.0",
"postcss": "^8.4.49", "postcss": "^8.5.3",
"rollup": "^4.23.0" "rollup": "^4.30.1"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.31.0", "version": "0.37.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -15,7 +15,7 @@
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.7.9", "axios": "^1.8.2",
"bits-ui": "^0.22.0", "bits-ui": "^0.22.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
@@ -50,6 +50,6 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.21.0", "typescript-eslint": "^8.21.0",
"vite": "^6.0.11" "vite": "^6.2.1"
} }
} }

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') || event.url.pathname.startsWith('/lc')
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

@@ -18,36 +18,22 @@
selectedIds = $bindable(), selectedIds = $bindable(),
withoutSearch = false, withoutSearch = false,
selectionDisabled = false, selectionDisabled = false,
defaultSort,
onRefresh, onRefresh,
columns, columns,
rows rows
}: { }: {
items: Paginated<T>; items: Paginated<T>;
requestOptions?: SearchPaginationSortRequest; requestOptions: SearchPaginationSortRequest;
selectedIds?: string[]; selectedIds?: string[];
withoutSearch?: boolean; withoutSearch?: boolean;
selectionDisabled?: boolean; selectionDisabled?: boolean;
defaultSort?: { column: string; direction: 'asc' | 'desc' };
onRefresh: (requestOptions: SearchPaginationSortRequest) => Promise<Paginated<T>>; onRefresh: (requestOptions: SearchPaginationSortRequest) => Promise<Paginated<T>>;
columns: { label: string; hidden?: boolean; sortColumn?: string }[]; columns: { label: string; hidden?: boolean; sortColumn?: string }[];
rows: Snippet<[{ item: T }]>; rows: Snippet<[{ item: T }]>;
} = $props(); } = $props();
let searchValue = $state(''); let searchValue = $state('');
let availablePageSizes: number[] = [20, 50, 100];
if (!requestOptions) {
requestOptions = {
search: '',
sort: defaultSort,
pagination: {
page: items.pagination.currentPage,
limit: items.pagination.itemsPerPage
}
};
}
let availablePageSizes: number[] = [10, 20, 50, 100];
let allChecked = $derived.by(() => { let allChecked = $derived.by(() => {
if (!selectedIds || items.data.length === 0) return false; if (!selectedIds || items.data.length === 0) return false;
@@ -83,20 +69,20 @@
} }
async function onPageChange(page: number) { async function onPageChange(page: number) {
requestOptions!.pagination = { limit: items.pagination.itemsPerPage, page }; requestOptions.pagination = { limit: items.pagination.itemsPerPage, page };
onRefresh(requestOptions!); onRefresh(requestOptions);
} }
async function onPageSizeChange(size: number) { async function onPageSizeChange(size: number) {
requestOptions!.pagination = { limit: size, page: 1 }; requestOptions.pagination = { limit: size, page: 1 };
onRefresh(requestOptions!); onRefresh(requestOptions);
} }
async function onSort(column?: string, direction: 'asc' | 'desc' = 'asc') { async function onSort(column?: string, direction: 'asc' | 'desc' = 'asc') {
if (!column) return; if (!column) return;
requestOptions!.sort = { column, direction }; requestOptions.sort = { column, direction };
onRefresh(requestOptions!); onRefresh(requestOptions);
} }
</script> </script>
@@ -115,8 +101,8 @@
{#if items.data.length === 0 && searchValue === ''} {#if items.data.length === 0 && searchValue === ''}
<div class="my-5 flex flex-col items-center"> <div class="my-5 flex flex-col items-center">
<Empty class="h-20 text-muted-foreground" /> <Empty class="text-muted-foreground h-20" />
<p class="mt-3 text-sm text-muted-foreground">No items found</p> <p class="text-muted-foreground mt-3 text-sm">No items found</p>
</div> </div>
{:else} {:else}
<Table.Root class="min-w-full table-auto overflow-x-auto"> <Table.Root class="min-w-full table-auto overflow-x-auto">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,46 +1,39 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { page } from '$app/state';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { Button } from './ui/button';
import * as Card from './ui/card'; import * as Card from './ui/card';
import WebAuthnUnsupported from './web-authn-unsupported.svelte';
import { page } from '$app/stores';
let { let {
children, children,
showEmailOneTimeAccessButton = false showAlternativeSignInMethodButton = false
}: { }: {
children: Snippet; children: Snippet;
showEmailOneTimeAccessButton?: boolean; showAlternativeSignInMethodButton?: boolean;
} = $props(); } = $props();
</script> </script>
<!-- Desktop --> <!-- Desktop -->
<div class="hidden h-screen items-center text-center lg:flex"> <div class="hidden h-screen items-center text-center lg:flex">
<div class="h-full min-w-[650px] p-16 {showEmailOneTimeAccessButton ? 'pb-0' : ''}"> <div class="h-full min-w-[650px] p-16 {showAlternativeSignInMethodButton ? 'pb-0' : ''}">
{#if browser && !browserSupportsWebAuthn()} <div class="flex h-full flex-col">
<WebAuthnUnsupported /> <div class="flex flex-grow flex-col items-center justify-center">
{:else} {@render children()}
<div class="flex h-full flex-col">
<div class="flex flex-grow flex-col items-center justify-center">
{@render children()}
</div>
{#if showEmailOneTimeAccessButton}
<div class="mb-4 flex justify-center">
<Button
href="/login/email?redirect={encodeURIComponent(
$page.url.pathname + $page.url.search
)}"
variant="link"
class="text-xs text-muted-foreground"
>
Don't have access to your passkey?
</Button>
</div>
{/if}
</div> </div>
{/if} {#if showAlternativeSignInMethodButton}
<div class="mb-4 flex justify-center">
<a
href={page.url.pathname == '/login'
? '/login/alternative'
: `/login/alternative?redirect=${encodeURIComponent(
page.url.pathname + page.url.search
)}`}
class="text-muted-foreground text-xs"
>
Don't have access to your passkey?
</a>
</div>
{/if}
</div>
</div> </div>
<img <img
src="/api/application-configuration/background-image" src="/api/application-configuration/background-image"
@@ -55,25 +48,20 @@
> >
<Card.Root class="mx-3"> <Card.Root class="mx-3">
<Card.CardContent <Card.CardContent
class="px-4 py-10 sm:p-10 {showEmailOneTimeAccessButton ? 'pb-3 sm:pb-3' : ''}" class="px-4 py-10 sm:p-10 {showAlternativeSignInMethodButton ? 'pb-3 sm:pb-3' : ''}"
> >
{#if browser && !browserSupportsWebAuthn()} {@render children()}
<WebAuthnUnsupported /> {#if showAlternativeSignInMethodButton}
{:else} <a
{@render children()} href={page.url.pathname == '/login'
{#if showEmailOneTimeAccessButton} ? '/login/alternative'
<div class="mt-5"> : `/login/alternative?redirect=${encodeURIComponent(
<Button page.url.pathname + page.url.search
href="/login/email?redirect={encodeURIComponent( )}`}
$page.url.pathname + $page.url.search class="text-muted-foreground mt-5 text-xs"
)}" >
variant="link" Don't have access to your passkey?
class="text-xs text-muted-foreground" </a>
>
Don't have access to your passkey?
</Button>
</div>
{/if}
{/if} {/if}
</Card.CardContent> </Card.CardContent>
</Card.Root> </Card.Root>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/state';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte'; import Input from '$lib/components/ui/input/input.svelte';
@@ -30,8 +30,8 @@
async function createOneTimeAccessToken() { async function createOneTimeAccessToken() {
try { try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000); const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
const token = await userService.createOneTimeAccessToken(userId!, expiration); const token = await userService.createOneTimeAccessToken(expiration, userId!);
oneTimeLink = `${$page.url.origin}/login/${token}`; oneTimeLink = `${page.url.origin}/lc/${token}`;
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
@@ -48,10 +48,9 @@
<Dialog.Root open={!!userId} {onOpenChange}> <Dialog.Root open={!!userId} {onOpenChange}>
<Dialog.Content class="max-w-md"> <Dialog.Content class="max-w-md">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>One Time Link</Dialog.Title> <Dialog.Title>Login Code</Dialog.Title>
<Dialog.Description <Dialog.Description
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or >Create a login code that the user can use to sign in without a passkey once.</Dialog.Description
have lost it.</Dialog.Description
> >
</Dialog.Header> </Dialog.Header>
{#if oneTimeLink === null} {#if oneTimeLink === null}
@@ -76,11 +75,11 @@
</Select.Root> </Select.Root>
</div> </div>
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}> <Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}>
Generate Link Generate Code
</Button> </Button>
{:else} {:else}
<Label for="one-time-link" class="sr-only">One Time Link</Label> <Label for="login-code" class="sr-only">Login Code</Label>
<Input id="one-time-link" value={oneTimeLink} readonly /> <Input id="login-code" value={oneTimeLink} readonly />
{/if} {/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View File

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

View File

@@ -2,6 +2,7 @@
import { cn } from '$lib/utils/style.js'; import { cn } from '$lib/utils/style.js';
import { Button as ButtonPrimitive } from 'bits-ui'; import { Button as ButtonPrimitive } from 'bits-ui';
import LoaderCircle from 'lucide-svelte/icons/loader-circle'; import LoaderCircle from 'lucide-svelte/icons/loader-circle';
import type { ClassNameValue } from 'tailwind-merge';
import { type Events, type Props, buttonVariants } from './index.js'; import { type Events, type Props, buttonVariants } from './index.js';
type $$Props = Props; type $$Props = Props;
@@ -19,7 +20,7 @@
<ButtonPrimitive.Root <ButtonPrimitive.Root
{builders} {builders}
disabled={isLoading || disabled} disabled={isLoading || disabled}
class={cn(buttonVariants({ variant, size, className }))} class={cn(buttonVariants({ variant, size, className: className as ClassNameValue }))}
type="button" type="button"
{...$$restProps} {...$$restProps}
on:click on:click

View File

@@ -3,7 +3,7 @@
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js'; import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLSpanElement>; type $$Props = HTMLAttributes<HTMLSpanElement> & { class?: string | null | undefined };
let className: string | undefined | null = undefined; let className: string | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </script>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
import { onMount } from 'svelte';
let {
selectionDisabled = false,
selectedGroupIds = $bindable()
}: {
selectionDisabled?: boolean;
selectedGroupIds: string[];
} = $props();
const userGroupService = new UserGroupService();
let groups: Paginated<UserGroup> | undefined = $state();
let requestOptions: SearchPaginationSortRequest = $state({
sort: {
column: 'friendlyName',
direction: 'asc'
}
});
onMount(async () => {
groups = await userGroupService.list(requestOptions);
});
</script>
{#if groups}
<AdvancedTable
items={groups}
{requestOptions}
onRefresh={async (o) => (groups = await userGroupService.list(o))}
columns={[{ label: 'Name', sortColumn: 'friendlyName' }]}
bind:selectedIds={selectedGroupIds}
{selectionDisabled}
>
{#snippet rows({ item })}
<Table.Cell>{item.name}</Table.Cell>
{/snippet}
</AdvancedTable>
{/if}

View File

@@ -3,11 +3,11 @@
</script> </script>
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
<div class="mx-auto rounded-2xl bg-muted p-3"> <div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" /> <Logo class="h-10 w-10" />
</div> </div>
<p class="mt-5 font-playfair text-3xl font-bold sm:text-4xl">Browser unsupported</p> <p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Browser unsupported</p>
<p class="mt-3 text-muted-foreground"> <p class="text-muted-foreground mt-3">
This browser doesn't support passkeys. Please use a browser that supports WebAuthn to sign in. This browser doesn't support passkeys. Please or use a alternative sign in method.
</p> </p>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { version as currentVersion } from '$app/environment'; import { version as currentVersion } from '$app/environment';
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration'; import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
import axios, { AxiosError } from 'axios'; import axios from 'axios';
import APIService from './api-service'; import APIService from './api-service';
export default class AppConfigService extends APIService { export default class AppConfigService extends APIService {
@@ -57,15 +57,11 @@ export default class AppConfigService extends APIService {
async getVersionInformation() { async getVersionInformation() {
const response = await axios const response = await axios
.get('https://api.github.com/repos/pocket-id/pocket-id/releases/latest') .get('https://api.github.com/repos/pocket-id/pocket-id/releases/latest', {
timeout: 2000
})
.then((res) => res.data) .then((res) => res.data)
.catch((e) => { .catch(() => null);
console.error(
'Failed to fetch version information',
e instanceof AxiosError && e.response ? e.response.data.message : e
);
return null;
});
let newestVersion: string | null = null; let newestVersion: string | null = null;
let isUpToDate: boolean | null = null; let isUpToDate: boolean | null = null;
@@ -95,7 +91,7 @@ export default class AppConfigService extends APIService {
return true; return true;
} else if (value === 'false') { } else if (value === 'false') {
return false; return false;
} else if (!isNaN(parseFloat(value))) { } else if (/^-?\d+(\.\d+)?$/.test(value)) {
return parseFloat(value); return parseFloat(value);
} else { } else {
return value; return value;

View File

@@ -1,4 +1,5 @@
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';
import APIService from './api-service'; import APIService from './api-service';
@@ -25,6 +26,11 @@ export default class UserService extends APIService {
return res.data as User; return res.data as User;
} }
async getUserGroups(userId: string) {
const res = await this.api.get(`/users/${userId}/groups`);
return res.data as UserGroup[];
}
async update(id: string, user: UserCreate) { async update(id: string, user: UserCreate) {
const res = await this.api.put(`/users/${id}`, user); const res = await this.api.put(`/users/${id}`, user);
return res.data as User; return res.data as User;
@@ -39,7 +45,21 @@ export default class UserService extends APIService {
await this.api.delete(`/users/${id}`); await this.api.delete(`/users/${id}`);
} }
async createOneTimeAccessToken(userId: string, expiresAt: Date) { 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(expiresAt: Date, userId: string) {
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,
expiresAt expiresAt
@@ -55,4 +75,9 @@ export default class UserService extends APIService {
async requestOneTimeAccessEmail(email: string, redirectPath?: string) { async requestOneTimeAccessEmail(email: string, redirectPath?: string) {
await this.api.post('/one-time-access-email', { email, redirectPath }); await this.api.post('/one-time-access-email', { email, redirectPath });
} }
async updateUserGroups(id: string, userGroupIds: string[]) {
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
return res.data as User;
}
} }

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte'; import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
@@ -60,11 +59,7 @@
onSuccess(code, callbackURL); onSuccess(code, callbackURL);
}); });
} catch (e) { } catch (e) {
if (e instanceof AxiosError && e.response?.data.error === 'Missing authorization') { errorMessage = getWebauthnErrorMessage(e);
authorizationRequired = true;
} else {
errorMessage = getWebauthnErrorMessage(e);
}
isLoading = false; isLoading = false;
} }
} }
@@ -88,7 +83,7 @@
{#if client == null} {#if client == null}
<p>Client not found</p> <p>Client not found</p>
{:else} {:else}
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}> <SignInWrapper showAlternativeSignInMethodButton>
<ClientProviderImages {client} {success} error={!!errorMessage} /> <ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if errorMessage} {#if errorMessage}

View File

@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
// Alias for /login/alternative/code
export function GET({ url }) {
let targetPath = '/login/alternative/code';
if (url.searchParams.has('redirect')) {
targetPath += `?redirect=${encodeURIComponent(url.searchParams.get('redirect')!)}`;
}
return redirect(307, targetPath);
}

View File

@@ -0,0 +1,15 @@
import { redirect } from '@sveltejs/kit';
// Alias for /login/alternative/code?code=...
export function GET({ url, params }) {
const targetPath = '/login/alternative/code';
const searchParams = new URLSearchParams();
searchParams.set('code', params.code);
if (url.searchParams.has('redirect')) {
searchParams.set('redirect', url.searchParams.get('redirect')!);
}
return redirect(307, `${targetPath}?${searchParams.toString()}`);
}

View File

@@ -35,19 +35,19 @@
<title>Sign In</title> <title>Sign In</title>
</svelte:head> </svelte:head>
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}> <SignInWrapper showAlternativeSignInMethodButton>
<div class="flex justify-center"> <div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} /> <LoginLogoErrorSuccessIndicator error={!!error} />
</div> </div>
<h1 class="mt-5 font-playfair text-3xl font-bold sm:text-4xl"> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName} Sign in to {$appConfigStore.appName}
</h1> </h1>
{#if error} {#if error}
<p class="mt-2 text-muted-foreground" in:fade> <p class="text-muted-foreground mt-2" in:fade>
{error}. Please try to sign in again. {error}. Please try to sign in again.
</p> </p>
{:else} {:else}
<p class="mt-2 text-muted-foreground" in:fade> <p class="text-muted-foreground mt-2" in:fade>
Authenticate yourself with your passkey to access the admin panel. Authenticate yourself with your passkey to access the admin panel.
</p> </p>
{/if} {/if}

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { page } from '$app/state';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import appConfigStore from '$lib/stores/application-configuration-store';
import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from 'lucide-svelte';
const methods = [
{
icon: LucideRectangleEllipsis,
title: 'Login Code',
description: 'Enter a login code to sign in.',
href: '/login/alternative/code'
}
];
if ($appConfigStore.emailOneTimeAccessEnabled) {
methods.push({
icon: LucideMail,
title: 'Email Login',
description: 'Request a login code via email.',
href: '/login/alternative/email'
});
}
</script>
<svelte:head>
<title>Sign In</title>
</svelte:head>
<SignInWrapper>
<div class="flex h-full flex-col justify-center">
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Alternative Sign In</h1>
<p class="text-muted-foreground mt-3">
If you dont't have access to your passkey, you can sign in using one of the following methods.
</p>
<div class="mt-5 flex flex-col gap-3">
{#each methods as method}
<a href={method.href + page.url.search}>
<Card.Root>
<Card.Content class="flex items-center justify-between p-4">
<div class="flex gap-3">
<method.icon class="text-primary h-7 w-7" />
<div class="text-start">
<h3 class="text-lg font-semibold">{method.title}</h3>
<p class="text-muted-foreground text-sm">{method.description}</p>
</div>
</div>
<Button variant="ghost"><LucideChevronRight class="h-5 w-5" /></Button>
</Card.Content>
</Card.Root>
</a>
{/each}
</div>
<a class="text-muted-foreground mt-5 text-xs" href={'/login' + page.url.search}
>Use your passkey instead?</a
>
</div>
</SignInWrapper>

View File

@@ -1,8 +1,8 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, url }) => { export const load: PageServerLoad = async ({ url }) => {
return { return {
token: params.token, code: url.searchParams.get('code'),
redirect: url.searchParams.get('redirect') || '/settings' redirect: url.searchParams.get('redirect') || '/settings'
}; };
}; };

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button';
import Input from '$lib/components/ui/input/input.svelte';
import UserService from '$lib/services/user-service';
import userStore from '$lib/stores/user-store.js';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { page } from '$app/state';
let { data } = $props();
let code = $state(data.code ?? '');
let isLoading = $state(false);
let error: string | undefined = $state();
const userService = new UserService();
async function authenticate() {
isLoading = true;
try {
const user = await userService.exchangeOneTimeAccessToken(code);
userStore.setUser(user);
try {
goto(data.redirect);
} catch (e) {
error = 'Invalid redirect URL';
}
} catch (e) {
error = getAxiosErrorMessage(e);
}
isLoading = false;
}
onMount(() => {
if (code) {
authenticate();
}
});
</script>
<svelte:head>
<title>Login Code</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">Login Code</h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. Please try again.
</p>
{:else}
<p class="text-muted-foreground mt-2">Enter the code you received to sign in.</p>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
authenticate();
}}
class="w-full max-w-[450px]"
>
<Input id="Email" class="mt-7" placeholder="Code" bind:value={code} type="text" />
<div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>Go back</Button>
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
</div>
</form>
</SignInWrapper>

View File

@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import SignInWrapper from '$lib/components/login-wrapper.svelte'; import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Input from '$lib/components/ui/input/input.svelte'; import Input from '$lib/components/ui/input/input.svelte';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte'; import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
const { data } = $props(); const { data } = $props();
@@ -27,16 +28,16 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Email One Time Access</title> <title>Email Login</title>
</svelte:head> </svelte:head>
<SignInWrapper> <SignInWrapper>
<div class="flex justify-center"> <div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} /> <LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div> </div>
<h1 class="mt-5 font-playfair text-3xl font-bold sm:text-4xl">Email One Time Access</h1> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Email Login</h1>
{#if error} {#if error}
<p class="mt-2 text-muted-foreground" in:fade> <p class="text-muted-foreground mt-2" in:fade>
{error}. Please try again. {error}. Please try again.
</p> </p>
<div class="mt-10 flex w-full justify-stretch gap-2"> <div class="mt-10 flex w-full justify-stretch gap-2">
@@ -44,17 +45,25 @@
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button> <Button class="w-full" onclick={() => (error = undefined)}>Try again</Button>
</div> </div>
{:else if success} {:else if success}
<p class="mt-2 text-muted-foreground" in:fade> <p class="text-muted-foreground mt-2" in:fade>
An email has been sent to the provided email, if it exists in the system. An email has been sent to the provided email, if it exists in the system.
</p> </p>
<div class="mt-8 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
>Go back</Button
>
<Button class="w-full" href={'/login/alternative/code' + page.url.search}>Enter code</Button>
</div>
{:else} {:else}
<form onsubmit={requestEmail}> <form onsubmit={requestEmail} class="w-full max-w-[450px]">
<p class="mt-2 text-muted-foreground" in:fade> <p class="text-muted-foreground mt-2" in:fade>
Enter your email to receive an email with a one time access link. Enter your email address to receive an email with a login code.
</p> </p>
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} /> <Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
<div class="mt-8 flex justify-stretch gap-2"> <div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button> <Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
>Go back</Button
>
<Button class="w-full" type="submit" {isLoading}>Submit</Button> <Button class="w-full" type="submit" {isLoading}>Submit</Button>
</div> </div>
</form> </form>

View File

@@ -6,63 +6,44 @@
import appConfigStore from '$lib/stores/application-configuration-store.js'; import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js'; import userStore from '$lib/stores/user-store.js';
import { getAxiosErrorMessage } from '$lib/utils/error-util'; import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte'; import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
let { data } = $props();
let isLoading = $state(false); let isLoading = $state(false);
let error: string | undefined = $state(); let error: string | undefined = $state();
const skipPage = data.redirect !== '/settings';
const userService = new UserService(); const userService = new UserService();
async function authenticate() { async function authenticate() {
isLoading = true; isLoading = true;
try { try {
const user = await userService.exchangeOneTimeAccessToken(data.token); const user = await userService.exchangeOneTimeAccessToken('setup');
userStore.setUser(user); userStore.setUser(user);
try { goto('/settings');
goto(data.redirect);
} catch (e) {
error = 'Invalid redirect URL';
}
} catch (e) { } catch (e) {
error = getAxiosErrorMessage(e); error = getAxiosErrorMessage(e);
} }
isLoading = false; isLoading = false;
} }
onMount(() => {
if (skipPage) {
authenticate();
}
});
</script> </script>
<SignInWrapper> <SignInWrapper>
<div class="flex justify-center"> <div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} /> <LoginLogoErrorSuccessIndicator error={!!error} />
</div> </div>
<h1 class="mt-5 font-playfair text-4xl font-bold"> <h1 class="font-playfair mt-5 text-4xl font-bold">
{data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'} {`${$appConfigStore.appName} Setup`}
</h1> </h1>
{#if error} {#if error}
<p class="mt-2 text-muted-foreground"> <p class="text-muted-foreground mt-2">
{error}. Please try again. {error}. Please try again.
</p> </p>
{:else if !skipPage} {:else}
<p class="mt-2 text-muted-foreground"> <p class="text-muted-foreground mt-2">
{#if data.token === 'setup'} You're about to sign in to the initial admin account. Anyone with this link can access the
You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent
account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.
unauthorized access.
{:else}
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
you'll need to request a new link.
{/if}
</p> </p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button> <Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
{/if} {/if}

View File

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

View File

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

View File

@@ -11,7 +11,9 @@
import { startRegistration } from '@simplewebauthn/browser'; import { startRegistration } from '@simplewebauthn/browser';
import { LucideAlertTriangle } from 'lucide-svelte'; import { LucideAlertTriangle } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
import AccountForm from './account-form.svelte'; import AccountForm from './account-form.svelte';
import LoginCodeModal from './login-code-modal.svelte';
import PasskeyList from './passkey-list.svelte'; import PasskeyList from './passkey-list.svelte';
import RenamePasskeyModal from './rename-passkey-modal.svelte'; import RenamePasskeyModal from './rename-passkey-modal.svelte';
@@ -19,6 +21,7 @@
let account = $state(data.account); let account = $state(data.account);
let passkeys = $state(data.passkeys); let passkeys = $state(data.passkeys);
let passkeyToRename: Passkey | null = $state(null); let passkeyToRename: Passkey | null = $state(null);
let showLoginCodeModal: boolean = $state(false);
const userService = new UserService(); const userService = new UserService();
const webauthnService = new WebAuthnService(); const webauthnService = new WebAuthnService();
@@ -36,6 +39,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 +96,16 @@
</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">
@@ -95,7 +115,7 @@
Manage your passkeys that you can use to authenticate yourself. Manage your passkeys that you can use to authenticate yourself.
</Card.Description> </Card.Description>
</div> </div>
<Button size="sm" on:click={createPasskey}>Add Passkey</Button> <Button size="sm" class="ml-3" on:click={createPasskey}>Add Passkey</Button>
</div> </div>
</Card.Header> </Card.Header>
{#if passkeys.length != 0} {#if passkeys.length != 0}
@@ -104,7 +124,23 @@
</Card.Content> </Card.Content>
{/if} {/if}
</Card.Root> </Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Login Code</Card.Title>
<Card.Description class="mt-1">
Create a one-time login code to sign in from a different device without a passkey.
</Card.Description>
</div>
<Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}>Create</Button>
</div>
</Card.Header>
</Card.Root>
<RenamePasskeyModal <RenamePasskeyModal
bind:passkey={passkeyToRename} bind:passkey={passkeyToRename}
callback={async () => (passkeys = await webauthnService.listCredentials())} callback={async () => (passkeys = await webauthnService.listCredentials())}
/> />
<LoginCodeModal bind:show={showLoginCodeModal} />

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

@@ -0,0 +1,62 @@
<script lang="ts">
import { page } from '$app/state';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Separator } from '$lib/components/ui/separator';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
let {
show = $bindable()
}: {
show: boolean;
} = $props();
const userService = new UserService();
let code: string | null = $state(null);
$effect(() => {
if (show) {
const expiration = new Date(Date.now() + 15 * 60 * 1000);
userService
.createOneTimeAccessToken(expiration, 'me')
.then((c) => (code = c))
.catch((e) => axiosErrorToast(e));
}
});
function onOpenChange(open: boolean) {
if (!open) {
code = null;
show = false;
}
}
</script>
<Dialog.Root open={!!code} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Login Code</Dialog.Title>
<Dialog.Description
>Sign in using the following code. The code will expire in 15 minutes.
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col items-center gap-2">
<CopyToClipboard value={code!}>
<p class="text-3xl font-semibold">{code}</p>
</CopyToClipboard>
<div class="text-muted-foreground flex items-center justify-center gap-3">
<Separator />
<p class="text-nowrap text-xs">or visit</p>
<Separator />
</div>
<div>
<CopyToClipboard value={page.url.origin + '/lc/' + code!}>
<p data-testId="login-code-link">{page.url.origin + '/lc/' + code!}</p>
</CopyToClipboard>
</div>
</div>
</Dialog.Content>
</Dialog.Root>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,19 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import OIDCService from '$lib/services/oidc-service'; import OIDCService from '$lib/services/oidc-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const oidcService = new OIDCService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const oidcService = new OIDCService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const clients = await oidcService.listClients();
return clients; const clientsRequestOptions: SearchPaginationSortRequest = {
sort: {
column: 'name',
direction: 'asc'
}
};
const clients = await oidcService.listClients(clientsRequestOptions);
return { clients, clientsRequestOptions };
}; };

View File

@@ -14,7 +14,8 @@
import OIDCClientList from './oidc-client-list.svelte'; import OIDCClientList from './oidc-client-list.svelte';
let { data } = $props(); let { data } = $props();
let clients = $state(data); let clients = $state(data.clients);
let clientsRequestOptions = $state(data.clientsRequestOptions);
let expandAddClient = $state(false); let expandAddClient = $state(false);
const oidcService = new OIDCService(); const oidcService = new OIDCService();
@@ -71,6 +72,6 @@
<Card.Title>Manage OIDC Clients</Card.Title> <Card.Title>Manage OIDC Clients</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<OIDCClientList {clients} /> <OIDCClientList {clients} requestOptions={clientsRequestOptions} />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -7,8 +7,8 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import OidcService from '$lib/services/oidc-service'; import OidcService from '$lib/services/oidc-service';
import UserGroupService from '$lib/services/user-group-service';
import clientSecretStore from '$lib/stores/client-secret-store'; import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type'; import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -16,7 +16,6 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte'; import OidcForm from '../oidc-client-form.svelte';
import UserGroupSelection from '../user-group-selection.svelte';
let { data } = $props(); let { data } = $props();
let client = $state({ let client = $state({
@@ -26,13 +25,13 @@
let showAllDetails = $state(false); let showAllDetails = $state(false);
const oidcService = new OidcService(); const oidcService = new OidcService();
const userGroupService = new UserGroupService();
const setupDetails = $state({ const setupDetails = $state({
'Authorization URL': `https://${$page.url.hostname}/authorize`, 'Authorization URL': `https://${$page.url.hostname}/authorize`,
'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 +111,15 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="mb-2 flex"> <div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">Client ID</Label> <Label class="mb-0 w-44">Client ID</Label>
<CopyToClipboard value={client.id}> <CopyToClipboard value={client.id}>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span> <span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard> </CopyToClipboard>
</div> </div>
{#if !client.isPublic} {#if !client.isPublic}
<div class="mb-2 mt-1 flex items-center"> <div class="mb-2 mt-1 flex flex-col sm:flex-row sm:items-center">
<Label class="w-44">Client secret</Label> <Label class="mb-0 w-44">Client secret</Label>
{#if $clientSecretStore} {#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}> <CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret"> <span class="text-muted-foreground text-sm" data-testid="client-secret">
@@ -128,23 +127,25 @@
</span> </span>
</CopyToClipboard> </CopyToClipboard>
{:else} {:else}
<span class="text-muted-foreground text-sm" data-testid="client-secret" <div>
>••••••••••••••••••••••••••••••••</span <span class="text-muted-foreground text-sm" data-testid="client-secret"
> >••••••••••••••••••••••••••••••••</span
<Button >
class="ml-2" <Button
onclick={createClientSecret} class="ml-2"
size="sm" onclick={createClientSecret}
variant="ghost" size="sm"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button variant="ghost"
> aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if showAllDetails} {#if showAllDetails}
<div transition:slide> <div transition:slide>
{#each Object.entries(setupDetails) as [key, value]} {#each Object.entries(setupDetails) as [key, value]}
<div class="mb-5 flex"> <div class="mb-5 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">{key}</Label> <Label class="mb-0 w-44">{key}</Label>
<CopyToClipboard {value}> <CopyToClipboard {value}>
<span class="text-muted-foreground text-sm">{value}</span> <span class="text-muted-foreground text-sm">{value}</span>
@@ -174,9 +175,7 @@
title="Allowed User Groups" title="Allowed User Groups"
description="Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client." description="Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client."
> >
{#await userGroupService.list() then groups} <UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
<UserGroupSelection {groups} bind:selectedGroupIds={client.allowedUserGroupIds} />
{/await}
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button> <Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button>
</div> </div>

View File

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

View File

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

View File

@@ -11,14 +11,15 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './client-secret.svelte'; import OneTimeLinkModal from './client-secret.svelte';
let { clients: initialClients }: { clients: Paginated<OidcClient> } = $props(); let {
let clients = $state<Paginated<OidcClient>>(initialClients); clients = $bindable(),
let oneTimeLink = $state<string | null>(null); requestOptions
let requestOptions: SearchPaginationSortRequest | undefined = $state(); }: {
clients: Paginated<OidcClient>;
requestOptions: SearchPaginationSortRequest;
} = $props();
$effect(() => { let oneTimeLink = $state<string | null>(null);
clients = initialClients;
});
const oidcService = new OIDCService(); const oidcService = new OIDCService();

View File

@@ -1,34 +0,0 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import UserGroupService from '$lib/services/user-group-service';
import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
let {
groups: initialGroups,
selectionDisabled = false,
selectedGroupIds = $bindable()
}: {
groups: Paginated<UserGroup>;
selectionDisabled?: boolean;
selectedGroupIds: string[];
} = $props();
const userGroupService = new UserGroupService();
let groups = $state(initialGroups);
</script>
<AdvancedTable
items={groups}
onRefresh={async (o) => (groups = await userGroupService.list(o))}
columns={[{ label: 'Name', sortColumn: 'name' }]}
bind:selectedIds={selectedGroupIds}
{selectionDisabled}
>
{#snippet rows({ item })}
<Table.Cell>{item.name}</Table.Cell>
{/snippet}
</AdvancedTable>

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