mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-10 15:22:56 +03:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2d61e964c | ||
|
|
f1256322b6 | ||
|
|
7885ae011c | ||
|
|
6a8dd84ca9 | ||
|
|
eb1426ed26 | ||
|
|
a9713cf6a1 | ||
|
|
8e344f1151 | ||
|
|
04efc36115 | ||
|
|
2ee0bad2c0 | ||
|
|
d0da532240 | ||
|
|
8d55c7c393 | ||
|
|
0f14a93e1d | ||
|
|
37b24bed91 | ||
|
|
66090f36a8 | ||
|
|
ff34e3b925 | ||
|
|
91f254c7bb | ||
|
|
85db96b0ef | ||
|
|
12d60fea23 | ||
|
|
2d733fc79f | ||
|
|
a421d01e0c | ||
|
|
1026ee4f5b | ||
|
|
cddfe8fa4c | ||
|
|
ef25f6b6b8 | ||
|
|
1652cc65f3 | ||
|
|
4bafee4f58 | ||
|
|
e46471cc2d | ||
|
|
fde951b543 | ||
|
|
01a9de0b04 | ||
|
|
a1131bca9a | ||
|
|
9a167d4076 | ||
|
|
887c5e462a | ||
|
|
20eba1378e | ||
|
|
a6ae7ae287 | ||
|
|
840a672fc3 | ||
|
|
7446f853fc | ||
|
|
652ee6ad5d | ||
|
|
dca9e7a11a | ||
|
|
816c198a42 | ||
|
|
339837bec4 | ||
|
|
39b46e99a9 | ||
|
|
dc9e64de3d | ||
|
|
6207e10279 | ||
|
|
7550333fe2 | ||
|
|
3de1301fa8 | ||
|
|
c3980d3d28 | ||
|
|
4d0fff821e | ||
|
|
2e66211b7f | ||
|
|
2071d002fc |
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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
|
||||
|
||||
@@ -30,11 +30,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
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'
|
||||
uses: docker/login-action@v3
|
||||
|
||||
116
CHANGELOG.md
116
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ go 1.23.1
|
||||
|
||||
require (
|
||||
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/gin-gonic/gin v1.10.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/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
||||
golang.org/x/crypto v0.32.0
|
||||
golang.org/x/image v0.24.0
|
||||
golang.org/x/time v0.9.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
@@ -28,6 +33,7 @@ require (
|
||||
github.com/bytedance/sonic v1.12.8 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // 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/gin-contrib/sse v1.0.0 // 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/exp v0.0.0-20250106191152-7588d65b2ba8 // 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/text v0.21.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
google.golang.org/protobuf v1.36.4 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -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/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
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/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
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-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
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/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/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.8.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.6.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.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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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.14.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.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/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -2,6 +2,7 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"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/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -41,7 +43,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
|
||||
customClaimService := service.NewCustomClaimService(db)
|
||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||
testService := service.NewTestService(db, appConfigService)
|
||||
testService := service.NewTestService(db, appConfigService, jwtService)
|
||||
userGroupService := service.NewUserGroupService(db, appConfigService)
|
||||
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
|
||||
|
||||
@@ -79,8 +81,20 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
baseGroup := r.Group("/")
|
||||
controller.NewWellKnownController(baseGroup, jwtService)
|
||||
|
||||
// Run the server
|
||||
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
|
||||
// Get the listener
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package common
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/url"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
@@ -10,8 +11,9 @@ import (
|
||||
type DbProvider string
|
||||
|
||||
const (
|
||||
DbProviderSqlite DbProvider = "sqlite"
|
||||
DbProviderPostgres DbProvider = "postgres"
|
||||
DbProviderSqlite DbProvider = "sqlite"
|
||||
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 {
|
||||
@@ -25,6 +27,7 @@ type EnvConfigSchema struct {
|
||||
Host string `env:"HOST"`
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
|
||||
}
|
||||
|
||||
@@ -39,6 +42,7 @@ var EnvConfig = &EnvConfigSchema{
|
||||
Host: "0.0.0.0",
|
||||
MaxMindLicenseKey: "",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
UiConfigDisabled: false,
|
||||
}
|
||||
|
||||
@@ -58,4 +62,12 @@ func init() {
|
||||
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ type TokenInvalidOrExpiredError struct{}
|
||||
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||
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{}
|
||||
|
||||
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) 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{}
|
||||
|
||||
func (e *MissingPermissionError) Error() string {
|
||||
@@ -182,12 +194,40 @@ type OidcAccessDeniedError struct{}
|
||||
func (e *OidcAccessDeniedError) Error() string {
|
||||
return "You're not allowed to access this service"
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
func (e *UiConfigDisabledError) Error() string {
|
||||
return "The configuration can't be changed since the UI configuration is disabled"
|
||||
}
|
||||
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 }
|
||||
|
||||
@@ -27,7 +27,7 @@ func NewAppConfigController(
|
||||
}
|
||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||
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/background-image", acc.getBackgroundImageHandler)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,6 +23,9 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
|
||||
|
||||
group.POST("/oidc/token", oc.createTokensHandler)
|
||||
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.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) {
|
||||
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)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
@@ -122,6 +136,44 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
||||
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) {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(clientId)
|
||||
|
||||
@@ -38,5 +38,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tc.TestService.SetJWTKeys()
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -27,10 +27,19 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
|
||||
group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
|
||||
group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
|
||||
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.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/setup", uc.getSetupAccessTokenHandler)
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
searchTerm := c.Query("search")
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
@@ -142,13 +168,84 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||
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
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if own {
|
||||
input.UserID = c.GetString("userID")
|
||||
}
|
||||
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
@@ -158,6 +255,14 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
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) {
|
||||
var input dto.OneTimeAccessEmailDto
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input.UserIDs)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
|
||||
@@ -35,9 +35,10 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||
"authorization_endpoint": appUrl + "/authorize",
|
||||
"token_endpoint": appUrl + "/api/oidc/token",
|
||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||
"scopes_supported": []string{"openid", "profile", "email"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||
"response_types_supported": []string{"code", "id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
|
||||
@@ -21,7 +21,7 @@ type AppConfigUpdateDto struct {
|
||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||
SmtpUser string `json:"smtpUser"`
|
||||
SmtpPassword string `json:"smtpPassword"`
|
||||
SmtpTls string `json:"smtpTls"`
|
||||
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
|
||||
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||
LdapEnabled string `json:"ldapEnabled" binding:"required"`
|
||||
LdapUrl string `json:"ldapUrl"`
|
||||
@@ -36,6 +36,8 @@ type AppConfigUpdateDto struct {
|
||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
||||
|
||||
@@ -8,24 +8,27 @@ type PublicOidcClientDto struct {
|
||||
|
||||
type OidcClientDto struct {
|
||||
PublicOidcClientDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
}
|
||||
|
||||
type OidcClientWithAllowedUserGroupsDto struct {
|
||||
PublicOidcClientDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
@@ -58,3 +61,10 @@ type OidcCreateTokensDto struct {
|
||||
type OidcUpdateAllowedUserGroupsDto struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type UserDto struct {
|
||||
LastName string `json:"lastName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
UserGroups []UserGroupDto `json:"userGroups"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
}
|
||||
|
||||
@@ -23,7 +24,7 @@ type UserCreateDto struct {
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
UserID string `json:"userId"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -31,3 +32,7 @@ type OneTimeAccessEmailDto struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
RedirectPath string `json:"redirectPath"`
|
||||
}
|
||||
|
||||
type UserUpdateUserGroupDto struct {
|
||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@ import (
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
|
||||
@@ -43,6 +43,8 @@ type AppConfig struct {
|
||||
LdapAttributeUserEmail AppConfigVariable
|
||||
LdapAttributeUserFirstName AppConfigVariable
|
||||
LdapAttributeUserLastName AppConfigVariable
|
||||
LdapAttributeUserProfilePicture AppConfigVariable
|
||||
LdapAttributeGroupMember AppConfigVariable
|
||||
LdapAttributeGroupUniqueIdentifier AppConfigVariable
|
||||
LdapAttributeGroupName AppConfigVariable
|
||||
LdapAttributeAdminGroup AppConfigVariable
|
||||
|
||||
@@ -37,13 +37,14 @@ type OidcAuthorizationCode struct {
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs CallbackURLs
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID string
|
||||
@@ -56,9 +57,9 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
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 {
|
||||
return json.Unmarshal(v, cu)
|
||||
} 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)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
|
||||
if err := service.InitDbConfig(); err != nil {
|
||||
log.Fatalf("Failed to initialize app config service: %v", err)
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
@@ -96,8 +97,8 @@ var defaultDbConfig = model.AppConfig{
|
||||
},
|
||||
SmtpTls: model.AppConfigVariable{
|
||||
Key: "smtpTls",
|
||||
Type: "bool",
|
||||
DefaultValue: "true",
|
||||
Type: "string",
|
||||
DefaultValue: "none",
|
||||
},
|
||||
SmtpSkipCertVerify: model.AppConfigVariable{
|
||||
Key: "smtpSkipCertVerify",
|
||||
@@ -173,6 +174,15 @@ var defaultDbConfig = model.AppConfig{
|
||||
Key: "ldapAttributeUserLastName",
|
||||
Type: "string",
|
||||
},
|
||||
LdapAttributeUserProfilePicture: model.AppConfigVariable{
|
||||
Key: "ldapAttributeUserProfilePicture",
|
||||
Type: "string",
|
||||
},
|
||||
LdapAttributeGroupMember: model.AppConfigVariable{
|
||||
Key: "ldapAttributeGroupMember",
|
||||
Type: "string",
|
||||
DefaultValue: "member",
|
||||
},
|
||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
|
||||
Key: "ldapAttributeGroupUniqueIdentifier",
|
||||
Type: "string",
|
||||
|
||||
@@ -3,27 +3,23 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
htemplate "html/template"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
ttemplate "text/template"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
"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/utils/email"
|
||||
"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 {
|
||||
appConfigService *AppConfigService
|
||||
db *gorm.DB
|
||||
@@ -114,105 +110,57 @@ func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
|
||||
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
}
|
||||
|
||||
// Connect to the SMTP server
|
||||
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" {
|
||||
client, err = srv.connectToSmtpServer(smtpAddress)
|
||||
} else if port == "465" {
|
||||
client, err = srv.connectToSmtpServerUsingImplicitTLS(
|
||||
smtpAddress,
|
||||
tlsConfig,
|
||||
)
|
||||
} else {
|
||||
client, err = srv.connectToSmtpServerUsingStartTLS(
|
||||
// Connect to the SMTP server based on TLS setting
|
||||
switch srv.appConfigService.DbConfig.SmtpTls.Value {
|
||||
case "none":
|
||||
client, err = smtp.Dial(smtpAddress)
|
||||
case "tls":
|
||||
client, err = smtp.DialTLS(smtpAddress, tlsConfig)
|
||||
case "starttls":
|
||||
client, err = smtp.DialStartTLS(
|
||||
smtpAddress,
|
||||
tlsConfig,
|
||||
)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid SMTP TLS setting: %s", srv.appConfigService.DbConfig.SmtpTls.Value)
|
||||
}
|
||||
if err != nil {
|
||||
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
|
||||
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
|
||||
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
|
||||
|
||||
if smtpUser != "" || smtpPassword != "" {
|
||||
auth := smtp.PlainAuth("",
|
||||
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
)
|
||||
// Authenticate with plain auth
|
||||
auth := sasl.NewPlainClient("", smtpUser, smtpPassword)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
hostname, err := os.Hostname()
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// Get a writer to write the email data
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start data: %w", err)
|
||||
}
|
||||
|
||||
// Write the email content
|
||||
_, err = w.Write([]byte(c.String()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write email data: %w", err)
|
||||
}
|
||||
|
||||
// Close the writer
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
||||
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
|
||||
Path: "one-time-access",
|
||||
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
|
||||
return "One time access"
|
||||
return "Login Code"
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ type NewLoginTemplateData struct {
|
||||
}
|
||||
|
||||
type OneTimeAccessTemplateData = struct {
|
||||
Link string
|
||||
Code string
|
||||
LoginLink string
|
||||
LoginLinkWithCode string
|
||||
}
|
||||
|
||||
// this is list of all template paths used for preloading templates
|
||||
|
||||
@@ -21,7 +21,8 @@ import (
|
||||
)
|
||||
|
||||
type GeoLiteService struct {
|
||||
mutex sync.Mutex
|
||||
disableUpdater bool
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
var localhostIPNets = []*net.IPNet{
|
||||
@@ -43,6 +44,12 @@ var tailscaleIPNets = []*net.IPNet{
|
||||
func NewGeoLiteService() *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() {
|
||||
if err := service.updateDatabase(); err != nil {
|
||||
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.
|
||||
func (s *GeoLiteService) updateDatabase() error {
|
||||
if s.disableUpdater {
|
||||
// Avoid updating the GeoLite2 City database.
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.isDatabaseUpToDate() {
|
||||
log.Println("GeoLite2 City database is up-to-date.")
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
resp, err := http.Get(downloadUrl)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
@@ -28,8 +27,8 @@ const (
|
||||
)
|
||||
|
||||
type JwtService struct {
|
||||
publicKey *rsa.PublicKey
|
||||
privateKey *rsa.PrivateKey
|
||||
PublicKey *rsa.PublicKey
|
||||
PrivateKey *rsa.PrivateKey
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
@@ -72,7 +71,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
|
||||
if err != nil {
|
||||
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 {
|
||||
return errors.New("can't parse jwt private key: " + err.Error())
|
||||
}
|
||||
@@ -81,7 +80,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
|
||||
if err != nil {
|
||||
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 {
|
||||
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,
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
kid, err := s.generateKeyID(s.PublicKey)
|
||||
if err != nil {
|
||||
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.Header["kid"] = kid
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
return token.SignedString(s.PrivateKey)
|
||||
}
|
||||
|
||||
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, 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 {
|
||||
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
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
kid, err := s.generateKeyID(s.PublicKey)
|
||||
if err != nil {
|
||||
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.Header["kid"] = kid
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
return token.SignedString(s.PrivateKey)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
kid, err := s.generateKeyID(s.PublicKey)
|
||||
if err != nil {
|
||||
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.Header["kid"] = kid
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
return token.SignedString(s.PrivateKey)
|
||||
}
|
||||
|
||||
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, 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 {
|
||||
return nil, errors.New("couldn't handle this token")
|
||||
@@ -194,13 +193,30 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.Registered
|
||||
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.
|
||||
func (s *JwtService) GetJWK() (JWK, error) {
|
||||
if s.publicKey == nil {
|
||||
if s.PublicKey == nil {
|
||||
return JWK{}, errors.New("public key is not initialized")
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
kid, err := s.generateKeyID(s.PublicKey)
|
||||
if err != nil {
|
||||
return JWK{}, err
|
||||
}
|
||||
@@ -210,8 +226,8 @@ func (s *JwtService) GetJWK() (JWK, error) {
|
||||
Kty: "RSA",
|
||||
Use: "sig",
|
||||
Alg: "RS256",
|
||||
N: base64.RawURLEncoding.EncodeToString(s.publicKey.N.Bytes()),
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.publicKey.E)).Bytes()),
|
||||
N: base64.RawURLEncoding.EncodeToString(s.PublicKey.N.Bytes()),
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.PublicKey.E)).Bytes()),
|
||||
}
|
||||
|
||||
return jwk, nil
|
||||
@@ -246,14 +262,14 @@ func (s *JwtService) generateKeys() error {
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
publicKey := &privateKey.PublicKey
|
||||
s.publicKey = publicKey
|
||||
s.PublicKey = publicKey
|
||||
|
||||
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
|
||||
return err
|
||||
@@ -281,32 +297,3 @@ func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) er
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
@@ -70,12 +76,13 @@ func (s *LdapService) SyncGroups() error {
|
||||
baseDN := s.appConfigService.DbConfig.LdapBase.Value
|
||||
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
|
||||
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
|
||||
groupMemberOfAttribute := s.appConfigService.DbConfig.LdapAttributeGroupMember.Value
|
||||
filter := s.appConfigService.DbConfig.LdapUserGroupSearchFilter.Value
|
||||
|
||||
searchAttrs := []string{
|
||||
nameAttribute,
|
||||
uniqueIdentifierAttribute,
|
||||
"member",
|
||||
groupMemberOfAttribute,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
for _, value := range result.Entries {
|
||||
var usersToAddDto dto.UserGroupUpdateUsersDto
|
||||
var membersUserId []string
|
||||
|
||||
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
|
||||
@@ -99,14 +105,23 @@ func (s *LdapService) SyncGroups() error {
|
||||
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
|
||||
|
||||
// Get group members and add to the correct Group
|
||||
groupMembers := value.GetAttributeValues("member")
|
||||
groupMembers := value.GetAttributeValues(groupMemberOfAttribute)
|
||||
for _, member := range groupMembers {
|
||||
// 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
|
||||
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -117,22 +132,18 @@ func (s *LdapService) SyncGroups() error {
|
||||
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
|
||||
}
|
||||
|
||||
usersToAddDto = dto.UserGroupUpdateUsersDto{
|
||||
UserIDs: membersUserId,
|
||||
}
|
||||
|
||||
if databaseGroup.ID == "" {
|
||||
newGroup, err := s.groupService.Create(syncGroup)
|
||||
if err != nil {
|
||||
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, 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 {
|
||||
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
||||
return err
|
||||
@@ -176,6 +187,7 @@ func (s *LdapService) SyncUsers() error {
|
||||
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
|
||||
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
|
||||
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
|
||||
profilePictureAttribute := s.appConfigService.DbConfig.LdapAttributeUserProfilePicture.Value
|
||||
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
|
||||
filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value
|
||||
|
||||
@@ -188,6 +200,7 @@ func (s *LdapService) SyncUsers() error {
|
||||
emailAttribute,
|
||||
firstNameAttribute,
|
||||
lastNameAttribute,
|
||||
profilePictureAttribute,
|
||||
}
|
||||
|
||||
// Filters must start and finish with ()!
|
||||
@@ -236,9 +249,14 @@ func (s *LdapService) SyncUsers() error {
|
||||
if err != nil {
|
||||
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
|
||||
@@ -250,7 +268,7 @@ func (s *LdapService) SyncUsers() error {
|
||||
// Delete users that no longer exist in LDAP
|
||||
for _, user := range ldapUsersInDb {
|
||||
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)
|
||||
} else {
|
||||
log.Printf("Deleted user %s", user.Username)
|
||||
@@ -259,3 +277,33 @@ func (s *LdapService) SyncUsers() error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
|
||||
callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL)
|
||||
if err != nil {
|
||||
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) {
|
||||
client := model.OidcClient{
|
||||
Name: input.Name,
|
||||
CallbackURLs: input.CallbackURLs,
|
||||
CreatedByID: userID,
|
||||
IsPublic: input.IsPublic,
|
||||
PkceEnabled: input.IsPublic || input.PkceEnabled,
|
||||
Name: input.Name,
|
||||
CallbackURLs: input.CallbackURLs,
|
||||
LogoutCallbackURLs: input.LogoutCallbackURLs,
|
||||
CreatedByID: userID,
|
||||
IsPublic: input.IsPublic,
|
||||
PkceEnabled: input.IsPublic || input.PkceEnabled,
|
||||
}
|
||||
|
||||
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.CallbackURLs = input.CallbackURLs
|
||||
client.LogoutCallbackURLs = input.LogoutCallbackURLs
|
||||
client.IsPublic = input.IsPublic
|
||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||
|
||||
@@ -399,6 +401,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
"family_name": user.LastName,
|
||||
"name": user.FullName(),
|
||||
"preferred_username": user.Username,
|
||||
"picture": fmt.Sprintf("%s/api/users/%s/profile-picture.png", common.EnvConfig.AppURL, user.ID),
|
||||
}
|
||||
|
||||
if strings.Contains(scope, "profile") {
|
||||
@@ -460,6 +463,46 @@ func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAll
|
||||
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) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
@@ -506,12 +549,12 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
|
||||
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 == "" {
|
||||
return client.CallbackURLs[0], nil
|
||||
return urls[0], nil
|
||||
}
|
||||
|
||||
for _, callbackPattern := range client.CallbackURLs {
|
||||
for _, callbackPattern := range urls {
|
||||
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
|
||||
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -11,8 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
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/pocket-id/pocket-id/backend/internal/common"
|
||||
@@ -23,11 +24,12 @@ import (
|
||||
|
||||
type TestService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService) *TestService {
|
||||
return &TestService{db: db, appConfigService: appConfigService}
|
||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService {
|
||||
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
|
||||
}
|
||||
|
||||
func (s *TestService) SeedDatabase() error {
|
||||
@@ -112,11 +114,12 @@ func (s *TestService) SeedDatabase() error {
|
||||
Base: model.Base{
|
||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
},
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
||||
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -124,7 +127,7 @@ func (s *TestService) SeedDatabase() error {
|
||||
},
|
||||
Name: "Immich",
|
||||
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,
|
||||
AllowedUserGroups: []model.UserGroup{
|
||||
userGroups[1],
|
||||
@@ -288,6 +291,43 @@ func (s *TestService) ResetAppConfig() error {
|
||||
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
|
||||
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||
|
||||
@@ -103,16 +103,16 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allow
|
||||
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)
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
// Fetch the users based on UserIDs in input
|
||||
// Fetch the users based on the userIds
|
||||
var users []model.User
|
||||
if len(input.UserIDs) > 0 {
|
||||
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil {
|
||||
if len(userIds) > 0 {
|
||||
if err := s.db.Where("id IN (?)", userIds).Find(&users).Error; err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"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/dto"
|
||||
"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/email"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -44,10 +49,83 @@ func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils
|
||||
|
||||
func (s *UserService) GetUser(userID string) (model.User, error) {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
var user model.User
|
||||
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{}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -113,6 +197,11 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
|
||||
}
|
||||
|
||||
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
|
||||
isDisabled := s.appConfigService.DbConfig.EmailOneTimeAccessEnabled.Value != "true"
|
||||
if isDisabled {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
|
||||
// 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 {
|
||||
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
|
||||
if strings.HasPrefix(redirectPath, "/") {
|
||||
encodedRedirectPath := url.QueryEscape(redirectPath)
|
||||
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
|
||||
linkWithCode = fmt.Sprintf("%s?redirect=%s", linkWithCode, encodedRedirectPath)
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -141,7 +231,9 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
||||
Name: user.Username,
|
||||
Email: user.Email,
|
||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||
Link: link,
|
||||
Code: oneTimeAccessToken,
|
||||
LoginLink: link,
|
||||
LoginLinkWithCode: linkWithCode,
|
||||
})
|
||||
if err != nil {
|
||||
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) {
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
@@ -194,6 +293,33 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAg
|
||||
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) {
|
||||
var userCount int64
|
||||
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||
|
||||
97
backend/internal/utils/image/profile_picture.go
Normal file
97
backend/internal/utils/image/profile_picture.go
Normal 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
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (Pagin
|
||||
}
|
||||
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
pageSize = 20
|
||||
} else if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
33
backend/internal/utils/systemd/sdnotify.go
Normal file
33
backend/internal/utils/systemd/sdnotify.go
Normal 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
|
||||
}
|
||||
@@ -1,95 +1,92 @@
|
||||
{{ define "style" }}
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
padding: 32px;
|
||||
border-radius: 10px;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.warning {
|
||||
background-color: #ffd966;
|
||||
color: #7f6000;
|
||||
padding: 4px 12px;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.content {
|
||||
background-color: #fafafa;
|
||||
color: #333;
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.content h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.grid p {
|
||||
margin: 0;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.message {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.button {
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
padding: 0.7rem 1.5rem;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
/* Reset styles for email clients */
|
||||
body, table, td, p, a {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
body {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: 32px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.warning {
|
||||
background-color: #ffd966;
|
||||
color: #7f6000;
|
||||
padding: 4px 12px;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
margin: auto 0 auto auto;
|
||||
}
|
||||
.content {
|
||||
background-color: #fafafa;
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.content h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid td {
|
||||
width: 50%;
|
||||
padding-bottom: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.message {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.button {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
padding: 0.7rem 1.5rem;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
{{ define "base" }}
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
<div class="warning">Warning</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>New Sign-In Detected</h2>
|
||||
<div class="grid">
|
||||
{{ if and .Data.City .Data.Country }}
|
||||
<div>
|
||||
<p class="label">Approximate Location</p>
|
||||
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div>
|
||||
<p class="label">IP Address</p>
|
||||
<p>{{ .Data.IPAddress }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label">Device</p>
|
||||
<p>{{ .Data.Device }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label">Sign-In Time</p>
|
||||
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||
</div>
|
||||
</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>
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
<div class="warning">Warning</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>New Sign-In Detected</h2>
|
||||
<table class="grid">
|
||||
<tr>
|
||||
{{ if and .Data.City .Data.Country }}
|
||||
<td>
|
||||
<p class="label">Approximate Location</p>
|
||||
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||
</td>
|
||||
{{ end }}
|
||||
<td>
|
||||
<p class="label">IP Address</p>
|
||||
<p>{{ .Data.IPAddress }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p class="label">Device</p>
|
||||
<p>{{ .Data.Device }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p class="label">Sign-In Time</p>
|
||||
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<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 -}}
|
||||
@@ -6,12 +6,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>One-Time Access</h2>
|
||||
<h2>Login Code</h2>
|
||||
<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>
|
||||
<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>
|
||||
{{ end -}}
|
||||
@@ -1,8 +1,10 @@
|
||||
{{ 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 -}}
|
||||
|
||||
@@ -4,5 +4,5 @@ import "embed"
|
||||
|
||||
// Embedded file systems for the project
|
||||
|
||||
//go:embed email-templates images migrations
|
||||
//go:embed email-templates images migrations fonts
|
||||
var FS embed.FS
|
||||
|
||||
BIN
backend/resources/fonts/PlayfairDisplay-Bold.ttf
Normal file
BIN
backend/resources/fonts/PlayfairDisplay-Bold.ttf
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls JSONB;
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE app_config_variables SET value = 'true' WHERE key = 'smtpTls';
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls BLOB;
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE app_config_variables SET value = 'true' WHERE key = 'smtpTls';
|
||||
@@ -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';
|
||||
267
frontend/package-lock.json
generated
267
frontend/package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.30.0",
|
||||
"version": "0.36.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.30.0",
|
||||
"version": "0.36.0",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.8.2",
|
||||
"bits-ui": "^0.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto": "^1.0.1",
|
||||
@@ -45,7 +45,7 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.21.0",
|
||||
"vite": "^6.0.11"
|
||||
"vite": "^6.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -88,12 +88,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
|
||||
"integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
@@ -103,12 +104,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz",
|
||||
"integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -118,12 +120,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz",
|
||||
"integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -133,12 +136,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz",
|
||||
"integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -148,12 +152,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz",
|
||||
"integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -163,12 +168,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz",
|
||||
"integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -178,12 +184,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz",
|
||||
"integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -193,12 +200,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz",
|
||||
"integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -208,12 +216,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz",
|
||||
"integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -223,12 +232,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz",
|
||||
"integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -238,12 +248,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz",
|
||||
"integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -253,12 +264,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz",
|
||||
"integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -268,12 +280,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz",
|
||||
"integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -283,12 +296,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz",
|
||||
"integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -298,12 +312,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz",
|
||||
"integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -313,12 +328,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz",
|
||||
"integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -328,12 +344,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz",
|
||||
"integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -343,12 +360,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz",
|
||||
"integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -358,12 +376,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz",
|
||||
"integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -373,12 +392,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz",
|
||||
"integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -388,12 +408,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz",
|
||||
"integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -403,12 +424,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz",
|
||||
"integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
@@ -418,12 +440,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz",
|
||||
"integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -433,12 +456,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz",
|
||||
"integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -448,12 +472,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz",
|
||||
"integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -1888,9 +1913,10 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.9",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
|
||||
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
@@ -2219,10 +2245,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
|
||||
"integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -2230,31 +2257,31 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.24.2",
|
||||
"@esbuild/android-arm": "0.24.2",
|
||||
"@esbuild/android-arm64": "0.24.2",
|
||||
"@esbuild/android-x64": "0.24.2",
|
||||
"@esbuild/darwin-arm64": "0.24.2",
|
||||
"@esbuild/darwin-x64": "0.24.2",
|
||||
"@esbuild/freebsd-arm64": "0.24.2",
|
||||
"@esbuild/freebsd-x64": "0.24.2",
|
||||
"@esbuild/linux-arm": "0.24.2",
|
||||
"@esbuild/linux-arm64": "0.24.2",
|
||||
"@esbuild/linux-ia32": "0.24.2",
|
||||
"@esbuild/linux-loong64": "0.24.2",
|
||||
"@esbuild/linux-mips64el": "0.24.2",
|
||||
"@esbuild/linux-ppc64": "0.24.2",
|
||||
"@esbuild/linux-riscv64": "0.24.2",
|
||||
"@esbuild/linux-s390x": "0.24.2",
|
||||
"@esbuild/linux-x64": "0.24.2",
|
||||
"@esbuild/netbsd-arm64": "0.24.2",
|
||||
"@esbuild/netbsd-x64": "0.24.2",
|
||||
"@esbuild/openbsd-arm64": "0.24.2",
|
||||
"@esbuild/openbsd-x64": "0.24.2",
|
||||
"@esbuild/sunos-x64": "0.24.2",
|
||||
"@esbuild/win32-arm64": "0.24.2",
|
||||
"@esbuild/win32-ia32": "0.24.2",
|
||||
"@esbuild/win32-x64": "0.24.2"
|
||||
"@esbuild/aix-ppc64": "0.25.1",
|
||||
"@esbuild/android-arm": "0.25.1",
|
||||
"@esbuild/android-arm64": "0.25.1",
|
||||
"@esbuild/android-x64": "0.25.1",
|
||||
"@esbuild/darwin-arm64": "0.25.1",
|
||||
"@esbuild/darwin-x64": "0.25.1",
|
||||
"@esbuild/freebsd-arm64": "0.25.1",
|
||||
"@esbuild/freebsd-x64": "0.25.1",
|
||||
"@esbuild/linux-arm": "0.25.1",
|
||||
"@esbuild/linux-arm64": "0.25.1",
|
||||
"@esbuild/linux-ia32": "0.25.1",
|
||||
"@esbuild/linux-loong64": "0.25.1",
|
||||
"@esbuild/linux-mips64el": "0.25.1",
|
||||
"@esbuild/linux-ppc64": "0.25.1",
|
||||
"@esbuild/linux-riscv64": "0.25.1",
|
||||
"@esbuild/linux-s390x": "0.25.1",
|
||||
"@esbuild/linux-x64": "0.25.1",
|
||||
"@esbuild/netbsd-arm64": "0.25.1",
|
||||
"@esbuild/netbsd-x64": "0.25.1",
|
||||
"@esbuild/openbsd-arm64": "0.25.1",
|
||||
"@esbuild/openbsd-x64": "0.25.1",
|
||||
"@esbuild/sunos-x64": "0.25.1",
|
||||
"@esbuild/win32-arm64": "0.25.1",
|
||||
"@esbuild/win32-ia32": "0.25.1",
|
||||
"@esbuild/win32-x64": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-runner": {
|
||||
@@ -3553,9 +3580,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
|
||||
"integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -3570,6 +3597,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.8",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -4557,13 +4585,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.0.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz",
|
||||
"integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
|
||||
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.24.2",
|
||||
"postcss": "^8.4.49",
|
||||
"rollup": "^4.23.0"
|
||||
"esbuild": "^0.25.0",
|
||||
"postcss": "^8.5.3",
|
||||
"rollup": "^4.30.1"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.31.0",
|
||||
"version": "0.37.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.8.2",
|
||||
"bits-ui": "^0.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto": "^1.0.1",
|
||||
@@ -50,6 +50,6 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.21.0",
|
||||
"vite": "^6.0.11"
|
||||
"vite": "^6.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
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) {
|
||||
return new Response(null, {
|
||||
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, {
|
||||
status: 302,
|
||||
headers: { location: '/settings' }
|
||||
});
|
||||
}
|
||||
|
||||
if (event.url.pathname.startsWith('/settings/admin') && !isAdmin) {
|
||||
if (isAdminPath && !isAdmin) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: '/settings' }
|
||||
|
||||
@@ -18,36 +18,22 @@
|
||||
selectedIds = $bindable(),
|
||||
withoutSearch = false,
|
||||
selectionDisabled = false,
|
||||
defaultSort,
|
||||
onRefresh,
|
||||
columns,
|
||||
rows
|
||||
}: {
|
||||
items: Paginated<T>;
|
||||
requestOptions?: SearchPaginationSortRequest;
|
||||
requestOptions: SearchPaginationSortRequest;
|
||||
selectedIds?: string[];
|
||||
withoutSearch?: boolean;
|
||||
selectionDisabled?: boolean;
|
||||
defaultSort?: { column: string; direction: 'asc' | 'desc' };
|
||||
onRefresh: (requestOptions: SearchPaginationSortRequest) => Promise<Paginated<T>>;
|
||||
columns: { label: string; hidden?: boolean; sortColumn?: string }[];
|
||||
rows: Snippet<[{ item: T }]>;
|
||||
} = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
|
||||
if (!requestOptions) {
|
||||
requestOptions = {
|
||||
search: '',
|
||||
sort: defaultSort,
|
||||
pagination: {
|
||||
page: items.pagination.currentPage,
|
||||
limit: items.pagination.itemsPerPage
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let availablePageSizes: number[] = [10, 20, 50, 100];
|
||||
let availablePageSizes: number[] = [20, 50, 100];
|
||||
|
||||
let allChecked = $derived.by(() => {
|
||||
if (!selectedIds || items.data.length === 0) return false;
|
||||
@@ -83,20 +69,20 @@
|
||||
}
|
||||
|
||||
async function onPageChange(page: number) {
|
||||
requestOptions!.pagination = { limit: items.pagination.itemsPerPage, page };
|
||||
onRefresh(requestOptions!);
|
||||
requestOptions.pagination = { limit: items.pagination.itemsPerPage, page };
|
||||
onRefresh(requestOptions);
|
||||
}
|
||||
|
||||
async function onPageSizeChange(size: number) {
|
||||
requestOptions!.pagination = { limit: size, page: 1 };
|
||||
onRefresh(requestOptions!);
|
||||
requestOptions.pagination = { limit: size, page: 1 };
|
||||
onRefresh(requestOptions);
|
||||
}
|
||||
|
||||
async function onSort(column?: string, direction: 'asc' | 'desc' = 'asc') {
|
||||
if (!column) return;
|
||||
|
||||
requestOptions!.sort = { column, direction };
|
||||
onRefresh(requestOptions!);
|
||||
requestOptions.sort = { column, direction };
|
||||
onRefresh(requestOptions);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -115,8 +101,8 @@
|
||||
|
||||
{#if items.data.length === 0 && searchValue === ''}
|
||||
<div class="my-5 flex flex-col items-center">
|
||||
<Empty class="h-20 text-muted-foreground" />
|
||||
<p class="mt-3 text-sm text-muted-foreground">No items found</p>
|
||||
<Empty class="text-muted-foreground h-20" />
|
||||
<p class="text-muted-foreground mt-3 text-sm">No items found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root class="min-w-full table-auto overflow-x-auto">
|
||||
|
||||
@@ -27,15 +27,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={onClick}>
|
||||
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
|
||||
<Tooltip.Trigger>{@render children()}</Tooltip.Trigger>
|
||||
<Tooltip.Content onclick={copyToClipboard}>
|
||||
{#if copied}
|
||||
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
|
||||
{:else}
|
||||
<span>Click to copy</span>
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</button>
|
||||
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
|
||||
<Tooltip.Trigger class="text-start" tabindex={-1} onclick={onClick}>{@render children()}</Tooltip.Trigger>
|
||||
<Tooltip.Content onclick={copyToClipboard}>
|
||||
{#if copied}
|
||||
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
|
||||
{:else}
|
||||
<span>Click to copy</span>
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Label } from './ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let {
|
||||
id,
|
||||
@@ -31,7 +31,7 @@
|
||||
{label}
|
||||
</Label>
|
||||
{#if description}
|
||||
<p class="text-[0.8rem] text-muted-foreground">
|
||||
<p class="text-muted-foreground text-[0.8rem]">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { Input } from '$lib/components/ui/input';
|
||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||
@@ -2,7 +2,7 @@
|
||||
import { cn } from '$lib/utils/style';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
import type { VariantProps } from 'tailwind-variants';
|
||||
import type { buttonVariants } from './ui/button';
|
||||
import type { buttonVariants } from '$lib/components/ui/button';
|
||||
|
||||
let {
|
||||
id,
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { FormInput } from '$lib/utils/form-util';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { Input, type FormInputEvent } from './ui/input';
|
||||
import { Input, type FormInputEvent } from '$lib/components/ui/input';
|
||||
|
||||
let {
|
||||
input = $bindable(),
|
||||
@@ -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>
|
||||
@@ -3,22 +3,10 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import { createSHA256hash } from '$lib/utils/crypto-util';
|
||||
import { LucideLogOut, LucideUser } from 'lucide-svelte';
|
||||
|
||||
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() {
|
||||
await webauthnService.logout();
|
||||
window.location.reload();
|
||||
@@ -28,8 +16,7 @@
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
><Avatar.Root class="h-9 w-9">
|
||||
<Avatar.Image src={gravatarURL} />
|
||||
<Avatar.Fallback>{initials}</Avatar.Fallback>
|
||||
<Avatar.Image src="/api/users/me/profile-picture.png" />
|
||||
</Avatar.Root></DropdownMenu.Trigger
|
||||
>
|
||||
<DropdownMenu.Content class="min-w-40" align="start">
|
||||
@@ -39,7 +26,7 @@
|
||||
{$userStore?.firstName}
|
||||
{$userStore?.lastName}
|
||||
</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>
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
import Logo from '../logo.svelte';
|
||||
import HeaderAvatar from './header-avatar.svelte';
|
||||
|
||||
const authUrls = [/^\/authorize$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
|
||||
|
||||
let isAuthPage = $derived(
|
||||
!$page.error &&
|
||||
($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
|
||||
!$page.error && authUrls.some((pattern) => pattern.test($page.url.pathname))
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,46 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
||||
import { page } from '$app/state';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Button } from './ui/button';
|
||||
import * as Card from './ui/card';
|
||||
import WebAuthnUnsupported from './web-authn-unsupported.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let {
|
||||
children,
|
||||
showEmailOneTimeAccessButton = false
|
||||
showAlternativeSignInMethodButton = false
|
||||
}: {
|
||||
children: Snippet;
|
||||
showEmailOneTimeAccessButton?: boolean;
|
||||
showAlternativeSignInMethodButton?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!-- Desktop -->
|
||||
<div class="hidden h-screen items-center text-center lg:flex">
|
||||
<div class="h-full min-w-[650px] p-16 {showEmailOneTimeAccessButton ? 'pb-0' : ''}">
|
||||
{#if browser && !browserSupportsWebAuthn()}
|
||||
<WebAuthnUnsupported />
|
||||
{:else}
|
||||
<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 class="h-full min-w-[650px] p-16 {showAlternativeSignInMethodButton ? 'pb-0' : ''}">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex flex-grow flex-col items-center justify-center">
|
||||
{@render children()}
|
||||
</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>
|
||||
<img
|
||||
src="/api/application-configuration/background-image"
|
||||
@@ -55,25 +48,20 @@
|
||||
>
|
||||
<Card.Root class="mx-3">
|
||||
<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()}
|
||||
<WebAuthnUnsupported />
|
||||
{:else}
|
||||
{@render children()}
|
||||
{#if showEmailOneTimeAccessButton}
|
||||
<div class="mt-5">
|
||||
<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}
|
||||
{@render children()}
|
||||
{#if showAlternativeSignInMethodButton}
|
||||
<a
|
||||
href={page.url.pathname == '/login'
|
||||
? '/login/alternative'
|
||||
: `/login/alternative?redirect=${encodeURIComponent(
|
||||
page.url.pathname + page.url.search
|
||||
)}`}
|
||||
class="text-muted-foreground mt-5 text-xs"
|
||||
>
|
||||
Don't have access to your passkey?
|
||||
</a>
|
||||
{/if}
|
||||
</Card.CardContent>
|
||||
</Card.Root>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
@@ -30,8 +30,8 @@
|
||||
async function createOneTimeAccessToken() {
|
||||
try {
|
||||
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||
const token = await userService.createOneTimeAccessToken(userId!, expiration);
|
||||
oneTimeLink = `${$page.url.origin}/login/${token}`;
|
||||
const token = await userService.createOneTimeAccessToken(expiration, userId!);
|
||||
oneTimeLink = `${page.url.origin}/lc/${token}`;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
@@ -48,10 +48,9 @@
|
||||
<Dialog.Root open={!!userId} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>One Time Link</Dialog.Title>
|
||||
<Dialog.Title>Login Code</Dialog.Title>
|
||||
<Dialog.Description
|
||||
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or
|
||||
have lost it.</Dialog.Description
|
||||
>Create a login code that the user can use to sign in without a passkey once.</Dialog.Description
|
||||
>
|
||||
</Dialog.Header>
|
||||
{#if oneTimeLink === null}
|
||||
@@ -76,11 +75,11 @@
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}>
|
||||
Generate Link
|
||||
Generate Code
|
||||
</Button>
|
||||
{:else}
|
||||
<Label for="one-time-link" class="sr-only">One Time Link</Label>
|
||||
<Input id="one-time-link" value={oneTimeLink} readonly />
|
||||
<Label for="login-code" class="sr-only">Login Code</Label>
|
||||
<Input id="login-code" value={oneTimeLink} readonly />
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
{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}
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { cn } from '$lib/utils/style.js';
|
||||
import { Button as ButtonPrimitive } from 'bits-ui';
|
||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
|
||||
import type { ClassNameValue } from 'tailwind-merge';
|
||||
import { type Events, type Props, buttonVariants } from './index.js';
|
||||
|
||||
type $$Props = Props;
|
||||
@@ -19,7 +20,7 @@
|
||||
<ButtonPrimitive.Root
|
||||
{builders}
|
||||
disabled={isLoading || disabled}
|
||||
class={cn(buttonVariants({ variant, size, className }))}
|
||||
class={cn(buttonVariants({ variant, size, className: className as ClassNameValue }))}
|
||||
type="button"
|
||||
{...$$restProps}
|
||||
on:click
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
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;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
45
frontend/src/lib/components/user-group-selection.svelte
Normal file
45
frontend/src/lib/components/user-group-selection.svelte
Normal 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}
|
||||
@@ -3,11 +3,11 @@
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
<p class="mt-5 font-playfair text-3xl font-bold sm:text-4xl">Browser unsupported</p>
|
||||
<p class="mt-3 text-muted-foreground">
|
||||
This browser doesn't support passkeys. Please use a browser that supports WebAuthn to sign in.
|
||||
<p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Browser unsupported</p>
|
||||
<p class="text-muted-foreground mt-3">
|
||||
This browser doesn't support passkeys. Please or use a alternative sign in method.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { version as currentVersion } from '$app/environment';
|
||||
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import axios from 'axios';
|
||||
import APIService from './api-service';
|
||||
|
||||
export default class AppConfigService extends APIService {
|
||||
@@ -57,15 +57,11 @@ export default class AppConfigService extends APIService {
|
||||
|
||||
async getVersionInformation() {
|
||||
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)
|
||||
.catch((e) => {
|
||||
console.error(
|
||||
'Failed to fetch version information',
|
||||
e instanceof AxiosError && e.response ? e.response.data.message : e
|
||||
);
|
||||
return null;
|
||||
});
|
||||
.catch(() => null);
|
||||
|
||||
let newestVersion: string | null = null;
|
||||
let isUpToDate: boolean | null = null;
|
||||
@@ -95,7 +91,7 @@ export default class AppConfigService extends APIService {
|
||||
return true;
|
||||
} else if (value === 'false') {
|
||||
return false;
|
||||
} else if (!isNaN(parseFloat(value))) {
|
||||
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
|
||||
return parseFloat(value);
|
||||
} else {
|
||||
return value;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 APIService from './api-service';
|
||||
|
||||
@@ -25,6 +26,11 @@ export default class UserService extends APIService {
|
||||
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) {
|
||||
const res = await this.api.put(`/users/${id}`, user);
|
||||
return res.data as User;
|
||||
@@ -39,7 +45,21 @@ export default class UserService extends APIService {
|
||||
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`, {
|
||||
userId,
|
||||
expiresAt
|
||||
@@ -55,4 +75,9 @@ export default class UserService extends APIService {
|
||||
async requestOneTimeAccessEmail(email: string, redirectPath?: string) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export type AllAppConfig = AppConfig & {
|
||||
smtpFrom: string;
|
||||
smtpUser: string;
|
||||
smtpPassword: string;
|
||||
smtpTls: boolean;
|
||||
smtpTls: 'none' | 'starttls' | 'tls';
|
||||
smtpSkipCertVerify: boolean;
|
||||
emailLoginNotificationEnabled: boolean;
|
||||
// LDAP
|
||||
@@ -31,6 +31,8 @@ export type AllAppConfig = AppConfig & {
|
||||
ldapAttributeUserEmail: string;
|
||||
ldapAttributeUserFirstName: string;
|
||||
ldapAttributeUserLastName: string;
|
||||
ldapAttributeUserProfilePicture: string;
|
||||
ldapAttributeGroupMember: string;
|
||||
ldapAttributeGroupUniqueIdentifier: string;
|
||||
ldapAttributeGroupName: string;
|
||||
ldapAttributeAdminGroup: string;
|
||||
@@ -45,5 +47,5 @@ export type AppConfigRawResponse = {
|
||||
export type AppVersionInformation = {
|
||||
isUpToDate: boolean | null;
|
||||
newestVersion: string | null;
|
||||
currentVersion: string
|
||||
currentVersion: string;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export type OidcClient = {
|
||||
name: string;
|
||||
logoURL: string;
|
||||
callbackURLs: [string, ...string[]];
|
||||
logoutCallbackURLs: string[];
|
||||
hasLogo: boolean;
|
||||
isPublic: boolean;
|
||||
pkceEnabled: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomClaim } from './custom-claim.type';
|
||||
import type { UserGroup } from './user-group.type';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
@@ -7,8 +8,9 @@ export type User = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isAdmin: boolean;
|
||||
userGroups: UserGroup[];
|
||||
customClaims: CustomClaim[];
|
||||
ldapId?: string;
|
||||
};
|
||||
|
||||
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId'>;
|
||||
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { AxiosError } from 'axios';
|
||||
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
@@ -60,11 +59,7 @@
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof AxiosError && e.response?.data.error === 'Missing authorization') {
|
||||
authorizationRequired = true;
|
||||
} else {
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
}
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -88,7 +83,7 @@
|
||||
{#if client == null}
|
||||
<p>Client not found</p>
|
||||
{:else}
|
||||
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
|
||||
<SignInWrapper showAlternativeSignInMethodButton>
|
||||
<ClientProviderImages {client} {success} error={!!errorMessage} />
|
||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
|
||||
{#if errorMessage}
|
||||
|
||||
10
frontend/src/routes/lc/+server.ts
Normal file
10
frontend/src/routes/lc/+server.ts
Normal 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);
|
||||
}
|
||||
15
frontend/src/routes/lc/[code]/+server.ts
Normal file
15
frontend/src/routes/lc/[code]/+server.ts
Normal 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()}`);
|
||||
}
|
||||
@@ -35,19 +35,19 @@
|
||||
<title>Sign In</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
|
||||
<SignInWrapper showAlternativeSignInMethodButton>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</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}
|
||||
</h1>
|
||||
{#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.
|
||||
</p>
|
||||
{: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.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
65
frontend/src/routes/login/alternative/+page.svelte
Normal file
65
frontend/src/routes/login/alternative/+page.svelte
Normal 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>
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
return {
|
||||
token: params.token,
|
||||
code: url.searchParams.get('code'),
|
||||
redirect: url.searchParams.get('redirect') || '/settings'
|
||||
};
|
||||
};
|
||||
74
frontend/src/routes/login/alternative/code/+page.svelte
Normal file
74
frontend/src/routes/login/alternative/code/+page.svelte
Normal 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>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
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 { 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();
|
||||
|
||||
@@ -27,16 +28,16 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Email One Time Access</title>
|
||||
<title>Email Login</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
|
||||
</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}
|
||||
<p class="mt-2 text-muted-foreground" in:fade>
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{error}. Please try again.
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
{: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.
|
||||
</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}
|
||||
<form onsubmit={requestEmail}>
|
||||
<p class="mt-2 text-muted-foreground" in:fade>
|
||||
Enter your email to receive an email with a one time access link.
|
||||
<form onsubmit={requestEmail} class="w-full max-w-[450px]">
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
Enter your email address to receive an email with a login code.
|
||||
</p>
|
||||
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -6,63 +6,44 @@
|
||||
import appConfigStore from '$lib/stores/application-configuration-store.js';
|
||||
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';
|
||||
|
||||
let { data } = $props();
|
||||
let isLoading = $state(false);
|
||||
let error: string | undefined = $state();
|
||||
const skipPage = data.redirect !== '/settings';
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
async function authenticate() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const user = await userService.exchangeOneTimeAccessToken(data.token);
|
||||
const user = await userService.exchangeOneTimeAccessToken('setup');
|
||||
userStore.setUser(user);
|
||||
|
||||
try {
|
||||
goto(data.redirect);
|
||||
} catch (e) {
|
||||
error = 'Invalid redirect URL';
|
||||
}
|
||||
goto('/settings');
|
||||
} catch (e) {
|
||||
error = getAxiosErrorMessage(e);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (skipPage) {
|
||||
authenticate();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<SignInWrapper>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="mt-5 font-playfair text-4xl font-bold">
|
||||
{data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'}
|
||||
<h1 class="font-playfair mt-5 text-4xl font-bold">
|
||||
{`${$appConfigStore.appName} Setup`}
|
||||
</h1>
|
||||
{#if error}
|
||||
<p class="mt-2 text-muted-foreground">
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{error}. Please try again.
|
||||
</p>
|
||||
{:else if !skipPage}
|
||||
<p class="mt-2 text-muted-foreground">
|
||||
{#if data.token === 'setup'}
|
||||
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
|
||||
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}
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2">
|
||||
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
|
||||
unauthorized access.
|
||||
</p>
|
||||
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
|
||||
{/if}
|
||||
43
frontend/src/routes/logout/+page.svelte
Normal file
43
frontend/src/routes/logout/+page.svelte
Normal 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>
|
||||
@@ -33,41 +33,37 @@
|
||||
</script>
|
||||
|
||||
<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
|
||||
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">
|
||||
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
|
||||
</div>
|
||||
<div
|
||||
class="mx-auto grid items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"
|
||||
>
|
||||
<nav class="grid gap-4 text-sm text-muted-foreground">
|
||||
{#each links as { href, label }}
|
||||
<a {href} class={$page.url.pathname.startsWith(href) ? 'font-bold text-primary' : ''}>
|
||||
{label}
|
||||
</a>
|
||||
{/each}
|
||||
{#if $userStore?.isAdmin && versionInformation.isUpToDate === false}
|
||||
<a
|
||||
href="https://github.com/pocket-id/pocket-id/releases/latest"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" />
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
<nav class="text-muted-foreground grid gap-4 text-sm">
|
||||
{#each links as { href, label }}
|
||||
<a {href} class={$page.url.pathname.startsWith(href) ? 'text-primary font-bold' : ''}>
|
||||
{label}
|
||||
</a>
|
||||
{/each}
|
||||
{#if $userStore?.isAdmin && versionInformation.isUpToDate === false}
|
||||
<a
|
||||
href="https://github.com/pocket-id/pocket-id/releases/latest"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" />
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-5 overflow-x-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
<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
|
||||
class="text-foreground"
|
||||
href="https://github.com/pocket-id/pocket-id"
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { LucideAlertTriangle } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
|
||||
import AccountForm from './account-form.svelte';
|
||||
import LoginCodeModal from './login-code-modal.svelte';
|
||||
import PasskeyList from './passkey-list.svelte';
|
||||
import RenamePasskeyModal from './rename-passkey-modal.svelte';
|
||||
|
||||
@@ -19,6 +21,7 @@
|
||||
let account = $state(data.account);
|
||||
let passkeys = $state(data.passkeys);
|
||||
let passkeyToRename: Passkey | null = $state(null);
|
||||
let showLoginCodeModal: boolean = $state(false);
|
||||
|
||||
const userService = new UserService();
|
||||
const webauthnService = new WebAuthnService();
|
||||
@@ -36,6 +39,13 @@
|
||||
return success;
|
||||
}
|
||||
|
||||
async function updateProfilePicture(image: File) {
|
||||
await userService
|
||||
.updateCurrentUsersProfilePicture(image)
|
||||
.then(() => toast.success('Profile picture updated successfully'))
|
||||
.catch(axiosErrorToast);
|
||||
}
|
||||
|
||||
async function createPasskey() {
|
||||
try {
|
||||
const opts = await webauthnService.getRegistrationOptions();
|
||||
@@ -86,6 +96,16 @@
|
||||
</Card.Root>
|
||||
</fieldset>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Content class="pt-6">
|
||||
<ProfilePictureSettings
|
||||
userId="me"
|
||||
isLdapUser={!!account.ldapId}
|
||||
callback={updateProfilePicture}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -95,7 +115,7 @@
|
||||
Manage your passkeys that you can use to authenticate yourself.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button size="sm" on:click={createPasskey}>Add Passkey</Button>
|
||||
<Button size="sm" class="ml-3" on:click={createPasskey}>Add Passkey</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if passkeys.length != 0}
|
||||
@@ -104,7 +124,23 @@
|
||||
</Card.Content>
|
||||
{/if}
|
||||
</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
|
||||
bind:passkey={passkeyToRename}
|
||||
callback={async () => (passkeys = await webauthnService.listCredentials())}
|
||||
/>
|
||||
<LoginCodeModal bind:show={showLoginCodeModal} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 type { UserCreate } from '$lib/types/user.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
|
||||
62
frontend/src/routes/settings/account/login-code-modal.svelte
Normal file
62
frontend/src/routes/settings/account/login-code-modal.svelte
Normal 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>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||
import AppConfigService from '$lib/services/app-config-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
@@ -14,7 +13,6 @@
|
||||
let { data } = $props();
|
||||
let appConfig = $state(data.appConfig);
|
||||
|
||||
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
|
||||
const appConfigService = new AppConfigService();
|
||||
|
||||
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
|
||||
@@ -57,28 +55,26 @@
|
||||
<title>Application Configuration</title>
|
||||
</svelte:head>
|
||||
|
||||
<fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}>
|
||||
<CollapsibleCard id="application-configuration-general" title="General" defaultExpanded>
|
||||
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
<CollapsibleCard id="application-configuration-general" title="General" defaultExpanded>
|
||||
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
|
||||
<CollapsibleCard
|
||||
id="application-configuration-email"
|
||||
title="Email"
|
||||
description="Enable email notifications to alert users when a login is detected from a new device or
|
||||
<CollapsibleCard
|
||||
id="application-configuration-email"
|
||||
title="Email"
|
||||
description="Enable email notifications to alert users when a login is detected from a new device or
|
||||
location."
|
||||
>
|
||||
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
>
|
||||
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
|
||||
<CollapsibleCard
|
||||
id="application-configuration-ldap"
|
||||
title="LDAP"
|
||||
description="Configure LDAP settings to sync users and groups from an LDAP server."
|
||||
>
|
||||
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
</fieldset>
|
||||
<CollapsibleCard
|
||||
id="application-configuration-ldap"
|
||||
title="LDAP"
|
||||
description="Configure LDAP settings to sync users and groups from an LDAP server."
|
||||
>
|
||||
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
|
||||
<CollapsibleCard id="application-configuration-images" title="Images">
|
||||
<UpdateApplicationImages callback={updateImages} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { cn } from '$lib/utils/style';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<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 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 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 type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
@@ -18,6 +21,12 @@
|
||||
} = $props();
|
||||
|
||||
const appConfigService = new AppConfigService();
|
||||
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
|
||||
const tlsOptions = {
|
||||
none: 'None',
|
||||
starttls: 'StartTLS',
|
||||
tls: 'TLS'
|
||||
};
|
||||
|
||||
let isSendingTestEmail = $state(false);
|
||||
|
||||
@@ -27,7 +36,7 @@
|
||||
smtpUser: z.string(),
|
||||
smtpPassword: z.string(),
|
||||
smtpFrom: z.string().email(),
|
||||
smtpTls: z.boolean(),
|
||||
smtpTls: z.enum(['none', 'starttls', 'tls']),
|
||||
smtpSkipCertVerify: z.boolean(),
|
||||
emailOneTimeAccessEnabled: z.boolean(),
|
||||
emailLoginNotificationEnabled: z.boolean()
|
||||
@@ -86,46 +95,57 @@
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<h4 class="text-lg font-semibold">SMTP Configuration</h4>
|
||||
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
|
||||
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
||||
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
|
||||
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
||||
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
||||
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
||||
<CheckboxWithLabel
|
||||
id="tls"
|
||||
label="TLS"
|
||||
description="Enable TLS for the SMTP connection."
|
||||
bind:checked={$inputs.smtpTls.value}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="skip-cert-verify"
|
||||
label="Skip Certificate Verification"
|
||||
description="This can be useful for self-signed certificates."
|
||||
bind:checked={$inputs.smtpSkipCertVerify.value}
|
||||
/>
|
||||
</div>
|
||||
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
|
||||
<div class="mt-4 flex flex-col gap-5">
|
||||
<CheckboxWithLabel
|
||||
id="email-login-notification"
|
||||
label="Email Login Notification"
|
||||
description="Send an email to the user when they log in from a new device."
|
||||
bind:checked={$inputs.emailLoginNotificationEnabled.value}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="email-one-time-access"
|
||||
label="Email One Time Access"
|
||||
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
|
||||
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={uiConfigDisabled}>
|
||||
<h4 class="text-lg font-semibold">SMTP Configuration</h4>
|
||||
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
|
||||
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
||||
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
|
||||
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
||||
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
||||
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
||||
<div class="grid gap-2">
|
||||
<Label class="mb-0" for="smtp-tls">SMTP TLS Option</Label>
|
||||
<Select.Root
|
||||
selected={{ value: $inputs.smtpTls.value, label: tlsOptions[$inputs.smtpTls.value] }}
|
||||
onSelectedChange={(v) => ($inputs.smtpTls.value = v!.value)}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value placeholder="Email TLS Option" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="none" label="None" />
|
||||
<Select.Item value="starttls" label="StartTLS" />
|
||||
<Select.Item value="tls" label="TLS" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<CheckboxWithLabel
|
||||
id="skip-cert-verify"
|
||||
label="Skip Certificate Verification"
|
||||
description="This can be useful for self-signed certificates."
|
||||
bind:checked={$inputs.smtpSkipCertVerify.value}
|
||||
/>
|
||||
</div>
|
||||
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
|
||||
<div class="mt-4 flex flex-col gap-5">
|
||||
<CheckboxWithLabel
|
||||
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">
|
||||
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
|
||||
>Send test email</Button
|
||||
>
|
||||
<Button type="submit">Save</Button>
|
||||
<Button type="submit" disabled={uiConfigDisabled}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
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 type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
@@ -15,6 +16,7 @@
|
||||
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
|
||||
let isLoading = $state(false);
|
||||
|
||||
const updatedAppConfig = {
|
||||
@@ -42,28 +44,30 @@
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="flex flex-col gap-5">
|
||||
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
||||
<FormInput
|
||||
label="Session Duration"
|
||||
type="number"
|
||||
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"
|
||||
label="Enable Self-Account Editing"
|
||||
description="Whether the users should be able to edit their own account details."
|
||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="emails-verified"
|
||||
label="Emails Verified"
|
||||
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">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
</div>
|
||||
<fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}>
|
||||
<div class="flex flex-col gap-5">
|
||||
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
||||
<FormInput
|
||||
label="Session Duration"
|
||||
type="number"
|
||||
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"
|
||||
label="Enable Self-Account Editing"
|
||||
description="Whether the users should be able to edit their own account details."
|
||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="emails-verified"
|
||||
label="Emails Verified"
|
||||
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">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
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 AppConfigService from '$lib/services/app-config-service';
|
||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
@@ -18,6 +19,7 @@
|
||||
} = $props();
|
||||
|
||||
const appConfigService = new AppConfigService();
|
||||
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
|
||||
|
||||
let ldapEnabled = $state(appConfig.ldapEnabled);
|
||||
let ldapSyncing = $state(false);
|
||||
@@ -36,6 +38,8 @@
|
||||
ldapAttributeUserEmail: appConfig.ldapAttributeUserEmail,
|
||||
ldapAttributeUserFirstName: appConfig.ldapAttributeUserFirstName,
|
||||
ldapAttributeUserLastName: appConfig.ldapAttributeUserLastName,
|
||||
ldapAttributeUserProfilePicture: appConfig.ldapAttributeUserProfilePicture,
|
||||
ldapAttributeGroupMember: appConfig.ldapAttributeGroupMember,
|
||||
ldapAttributeGroupUniqueIdentifier: appConfig.ldapAttributeGroupUniqueIdentifier,
|
||||
ldapAttributeGroupName: appConfig.ldapAttributeGroupName,
|
||||
ldapAttributeAdminGroup: appConfig.ldapAttributeAdminGroup
|
||||
@@ -54,6 +58,8 @@
|
||||
ldapAttributeUserEmail: z.string().min(1),
|
||||
ldapAttributeUserFirstName: z.string().min(1),
|
||||
ldapAttributeUserLastName: z.string().min(1),
|
||||
ldapAttributeUserProfilePicture: z.string(),
|
||||
ldapAttributeGroupMember: z.string(),
|
||||
ldapAttributeGroupUniqueIdentifier: z.string().min(1),
|
||||
ldapAttributeGroupName: z.string().min(1),
|
||||
ldapAttributeAdminGroup: z.string()
|
||||
@@ -97,88 +103,110 @@
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<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">
|
||||
<FormInput label="LDAP URL" placeholder="ldap://example.com:389" bind:input={$inputs.ldapUrl} />
|
||||
<FormInput
|
||||
label="LDAP Bind DN"
|
||||
placeholder="cn=people,dc=example,dc=com"
|
||||
bind:input={$inputs.ldapBindDn}
|
||||
/>
|
||||
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} />
|
||||
<FormInput label="LDAP Base DN" placeholder="dc=example,dc=com" bind:input={$inputs.ldapBase} />
|
||||
<FormInput
|
||||
label="User Search Filter"
|
||||
description="The Search filter to use to search/sync users."
|
||||
placeholder="(objectClass=person)"
|
||||
bind:input={$inputs.ldapUserSearchFilter}
|
||||
/>
|
||||
<FormInput
|
||||
label="Groups Search Filter"
|
||||
description="The Search filter to use to search/sync groups."
|
||||
placeholder="(objectClass=groupOfNames)"
|
||||
bind:input={$inputs.ldapUserGroupSearchFilter}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="skip-cert-verify"
|
||||
label="Skip Certificate Verification"
|
||||
description="This can be useful for self-signed certificates."
|
||||
bind:checked={$inputs.ldapSkipCertVerify.value}
|
||||
/>
|
||||
</div>
|
||||
<h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4>
|
||||
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
|
||||
<FormInput
|
||||
label="User Unique Identifier Attribute"
|
||||
description="The value of this attribute should never change."
|
||||
placeholder="uuid"
|
||||
bind:input={$inputs.ldapAttributeUserUniqueIdentifier}
|
||||
/>
|
||||
<FormInput
|
||||
label="Username Attribute"
|
||||
placeholder="uid"
|
||||
bind:input={$inputs.ldapAttributeUserUsername}
|
||||
/>
|
||||
<FormInput
|
||||
label="User Mail Attribute"
|
||||
placeholder="mail"
|
||||
bind:input={$inputs.ldapAttributeUserEmail}
|
||||
/>
|
||||
<FormInput
|
||||
label="User First Name Attribute"
|
||||
placeholder="givenName"
|
||||
bind:input={$inputs.ldapAttributeUserFirstName}
|
||||
/>
|
||||
<FormInput
|
||||
label="User Last Name Attribute"
|
||||
placeholder="sn"
|
||||
bind:input={$inputs.ldapAttributeUserLastName}
|
||||
/>
|
||||
<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 disabled={uiConfigDisabled}>
|
||||
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||
<FormInput
|
||||
label="LDAP URL"
|
||||
placeholder="ldap://example.com:389"
|
||||
bind:input={$inputs.ldapUrl}
|
||||
/>
|
||||
<FormInput
|
||||
label="LDAP Bind DN"
|
||||
placeholder="cn=people,dc=example,dc=com"
|
||||
bind:input={$inputs.ldapBindDn}
|
||||
/>
|
||||
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} />
|
||||
<FormInput
|
||||
label="LDAP Base DN"
|
||||
placeholder="dc=example,dc=com"
|
||||
bind:input={$inputs.ldapBase}
|
||||
/>
|
||||
<FormInput
|
||||
label="User Search Filter"
|
||||
description="The Search filter to use to search/sync users."
|
||||
placeholder="(objectClass=person)"
|
||||
bind:input={$inputs.ldapUserSearchFilter}
|
||||
/>
|
||||
<FormInput
|
||||
label="Groups Search Filter"
|
||||
description="The Search filter to use to search/sync groups."
|
||||
placeholder="(objectClass=groupOfNames)"
|
||||
bind:input={$inputs.ldapUserGroupSearchFilter}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="skip-cert-verify"
|
||||
label="Skip Certificate Verification"
|
||||
description="This can be useful for self-signed certificates."
|
||||
bind:checked={$inputs.ldapSkipCertVerify.value}
|
||||
/>
|
||||
</div>
|
||||
<h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4>
|
||||
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
|
||||
<FormInput
|
||||
label="User Unique Identifier Attribute"
|
||||
description="The value of this attribute should never change."
|
||||
placeholder="uuid"
|
||||
bind:input={$inputs.ldapAttributeUserUniqueIdentifier}
|
||||
/>
|
||||
<FormInput
|
||||
label="Username Attribute"
|
||||
placeholder="uid"
|
||||
bind:input={$inputs.ldapAttributeUserUsername}
|
||||
/>
|
||||
<FormInput
|
||||
label="User Mail Attribute"
|
||||
placeholder="mail"
|
||||
bind:input={$inputs.ldapAttributeUserEmail}
|
||||
/>
|
||||
<FormInput
|
||||
label="User First Name Attribute"
|
||||
placeholder="givenName"
|
||||
bind:input={$inputs.ldapAttributeUserFirstName}
|
||||
/>
|
||||
<FormInput
|
||||
label="User Last Name Attribute"
|
||||
placeholder="sn"
|
||||
bind:input={$inputs.ldapAttributeUserLastName}
|
||||
/>
|
||||
<FormInput
|
||||
label="User Profile Picture Attribute"
|
||||
description="The value of this attribute can either be a URL, a binary or a base64 encoded image."
|
||||
placeholder="jpegPhoto"
|
||||
bind:input={$inputs.ldapAttributeUserProfilePicture}
|
||||
/>
|
||||
<FormInput
|
||||
label="Group Members Attribute"
|
||||
description="The attribute to use for querying members of a group."
|
||||
placeholder="member"
|
||||
bind:input={$inputs.ldapAttributeGroupMember}
|
||||
/>
|
||||
<FormInput
|
||||
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">
|
||||
{#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 type="submit">Save</Button>
|
||||
<Button type="submit" disabled={uiConfigDisabled}>Save</Button>
|
||||
{:else}
|
||||
<Button onclick={onEnable}>Enable</Button>
|
||||
<Button onclick={onEnable} disabled={uiConfigDisabled}>Enable</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
||||
import OIDCService from '$lib/services/oidc-service';
|
||||
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
import OIDCClientList from './oidc-client-list.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let clients = $state(data);
|
||||
let clients = $state(data.clients);
|
||||
let clientsRequestOptions = $state(data.clientsRequestOptions);
|
||||
let expandAddClient = $state(false);
|
||||
|
||||
const oidcService = new OIDCService();
|
||||
@@ -71,6 +72,6 @@
|
||||
<Card.Title>Manage OIDC Clients</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<OIDCClientList {clients} />
|
||||
<OIDCClientList {clients} requestOptions={clientsRequestOptions} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
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 UserGroupService from '$lib/services/user-group-service';
|
||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
@@ -16,7 +16,6 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { slide } from 'svelte/transition';
|
||||
import OidcForm from '../oidc-client-form.svelte';
|
||||
import UserGroupSelection from '../user-group-selection.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let client = $state({
|
||||
@@ -26,13 +25,13 @@
|
||||
let showAllDetails = $state(false);
|
||||
|
||||
const oidcService = new OidcService();
|
||||
const userGroupService = new UserGroupService();
|
||||
|
||||
const setupDetails = $state({
|
||||
'Authorization URL': `https://${$page.url.hostname}/authorize`,
|
||||
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||
'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`,
|
||||
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
|
||||
});
|
||||
@@ -112,15 +111,15 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<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>
|
||||
<CopyToClipboard value={client.id}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{#if !client.isPublic}
|
||||
<div class="mb-2 mt-1 flex items-center">
|
||||
<Label class="w-44">Client secret</Label>
|
||||
<div class="mb-2 mt-1 flex flex-col sm:flex-row sm:items-center">
|
||||
<Label class="mb-0 w-44">Client secret</Label>
|
||||
{#if $clientSecretStore}
|
||||
<CopyToClipboard value={$clientSecretStore}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||
@@ -128,23 +127,25 @@
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>••••••••••••••••••••••••••••••••</span
|
||||
>
|
||||
<Button
|
||||
class="ml-2"
|
||||
onclick={createClientSecret}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
||||
>
|
||||
<div>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>••••••••••••••••••••••••••••••••</span
|
||||
>
|
||||
<Button
|
||||
class="ml-2"
|
||||
onclick={createClientSecret}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showAllDetails}
|
||||
<div transition:slide>
|
||||
{#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>
|
||||
<CopyToClipboard {value}>
|
||||
<span class="text-muted-foreground text-sm">{value}</span>
|
||||
@@ -174,9 +175,7 @@
|
||||
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."
|
||||
>
|
||||
{#await userGroupService.list() then groups}
|
||||
<UserGroupSelection {groups} bind:selectedGroupIds={client.allowedUserGroupIds} />
|
||||
{/await}
|
||||
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { Input } from '$lib/components/ui/input';
|
||||
import { LucideMinus, LucidePlus } from 'lucide-svelte';
|
||||
@@ -7,12 +7,16 @@
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
label,
|
||||
callbackURLs = $bindable(),
|
||||
error = $bindable(null),
|
||||
allowEmpty = false,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
label: string;
|
||||
callbackURLs: string[];
|
||||
error?: string | null;
|
||||
allowEmpty?: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
@@ -20,12 +24,12 @@
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
<FormInput label="Callback URLs">
|
||||
<FormInput {label}>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{#each callbackURLs as _, i}
|
||||
<div class="flex gap-x-2">
|
||||
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
|
||||
{#if callbackURLs.length > 1}
|
||||
{#if callbackURLs.length > 1 || allowEmpty}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -49,7 +53,7 @@
|
||||
on:click={() => (callbackURLs = [...callbackURLs, ''])}
|
||||
>
|
||||
<LucidePlus class="mr-1 h-4 w-4" />
|
||||
Add another
|
||||
{callbackURLs.length === 0 ? 'Add' : 'Add another'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FileInput from '$lib/components/file-input.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
|
||||
import FileInput from '$lib/components/form/file-input.svelte';
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import type {
|
||||
@@ -10,7 +10,7 @@
|
||||
OidcClientCreateWithLogo
|
||||
} from '$lib/types/oidc.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { set, z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||
|
||||
let {
|
||||
@@ -30,6 +30,7 @@
|
||||
const client: OidcClientCreate = {
|
||||
name: existingClient?.name || '',
|
||||
callbackURLs: existingClient?.callbackURLs || [''],
|
||||
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
|
||||
isPublic: existingClient?.isPublic || false,
|
||||
pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || false
|
||||
};
|
||||
@@ -37,6 +38,7 @@
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
callbackURLs: z.array(z.string()).nonempty(),
|
||||
logoutCallbackURLs: z.array(z.string()),
|
||||
isPublic: z.boolean(),
|
||||
pkceEnabled: z.boolean()
|
||||
});
|
||||
@@ -76,13 +78,22 @@
|
||||
</script>
|
||||
|
||||
<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} />
|
||||
<div></div>
|
||||
<OidcCallbackUrlInput
|
||||
label="Callback URLs"
|
||||
class="w-full"
|
||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||
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
|
||||
id="public-client"
|
||||
label="Public Client"
|
||||
@@ -104,7 +115,7 @@
|
||||
<Label for="logo">Logo</Label>
|
||||
<div class="mt-2 flex items-end gap-3">
|
||||
{#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
|
||||
class="m-auto max-h-full max-w-full object-contain"
|
||||
src={logoDataURL}
|
||||
|
||||
@@ -11,14 +11,15 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import OneTimeLinkModal from './client-secret.svelte';
|
||||
|
||||
let { clients: initialClients }: { clients: Paginated<OidcClient> } = $props();
|
||||
let clients = $state<Paginated<OidcClient>>(initialClients);
|
||||
let oneTimeLink = $state<string | null>(null);
|
||||
let requestOptions: SearchPaginationSortRequest | undefined = $state();
|
||||
let {
|
||||
clients = $bindable(),
|
||||
requestOptions
|
||||
}: {
|
||||
clients: Paginated<OidcClient>;
|
||||
requestOptions: SearchPaginationSortRequest;
|
||||
} = $props();
|
||||
|
||||
$effect(() => {
|
||||
clients = initialClients;
|
||||
});
|
||||
let oneTimeLink = $state<string | null>(null);
|
||||
|
||||
const oidcService = new OIDCService();
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user