Compare commits

..

35 Commits

Author SHA1 Message Date
Elias Schneider
74ba8390f4 release: 0.38.0 2025-03-10 20:52:35 +01:00
Elias Schneider
31198feec2 feat: add env variable to disable update check 2025-03-10 20:48:57 +01:00
Elias Schneider
e5ec264bfd fix: redirection not correctly if signing in with email code 2025-03-10 20:36:52 +01:00
Kot C
c822192124 fix: typo in account settings (#307) 2025-03-10 13:35:46 +00:00
Elias Schneider
f2d61e964c release: 0.37.0 2025-03-10 14:09:30 +01:00
dependabot[bot]
f1256322b6 chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates (#306)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 14:06:13 +01:00
Elias Schneider
7885ae011c tests: fix user group assignment test 2025-03-10 14:05:51 +01:00
Elias Schneider
6a8dd84ca9 fix: add back setup page 2025-03-10 13:00:08 +01:00
Jonas
eb1426ed26 feat(account): add ability to sign in with login code (#271)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-10 12:45:45 +01:00
Elias Schneider
a9713cf6a1 feat: increase default item count per page 2025-03-10 12:39:42 +01:00
Elias Schneider
8e344f1151 fix: make sorting consistent around tables 2025-03-10 12:37:16 +01:00
Elias Schneider
04efc36115 fix: add timeout to update check 2025-03-10 09:41:58 +01:00
Elias Schneider
2ee0bad2c0 docs: add Discord contact link to issue template 2025-03-07 14:25:19 +01:00
Elias Schneider
d0da532240 refactor: fix type errors 2025-03-07 13:56:24 +01:00
Elias Schneider
8d55c7c393 release: 0.36.0 2025-03-06 22:25:25 +01:00
Kyle Mendell
0f14a93e1d feat: display groups on the account page (#296)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-06 22:25:03 +01:00
Elias Schneider
37b24bed91 ci/cd: remove PR docker build action 2025-03-06 22:24:00 +01:00
Elias Schneider
66090f36a8 ci/cd: use github.repository variable intead of hardcoding the repository name 2025-03-06 19:13:44 +01:00
Kyle Mendell
ff34e3b925 fix: default sorting on tables (#299)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-06 17:42:31 +01:00
Savely Krasovsky
91f254c7bb feat: enable sd_notify support (#277) 2025-03-06 17:42:12 +01:00
Kyle Mendell
85db96b0ef ci/cd: add pr docker build (#293)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-06 16:29:33 +01:00
Elias Schneider
12d60fea23 release: 0.35.6 2025-03-03 16:49:55 +01:00
Elias Schneider
2d733fc79f fix: support LOGIN authentication method for SMTP (#292) 2025-03-03 16:48:38 +01:00
Elias Schneider
a421d01e0c release: 0.35.5 2025-03-03 16:48:07 +01:00
Elias Schneider
1026ee4f5b fix: profile picture orientation if image is rotated with EXIF 2025-03-03 09:06:52 +01:00
Elias Schneider
cddfe8fa4c release: 0.35.4 2025-03-01 20:42:53 +01:00
Jonas
ef25f6b6b8 fix: profile picture of other user can't be updated (#273) 2025-03-01 20:42:29 +01:00
Elias Schneider
1652cc65f3 fix: support POST for OIDC userinfo endpoint 2025-03-01 20:42:00 +01:00
Elias Schneider
4bafee4f58 fix: add groups scope and claim to well known endpoint 2025-03-01 20:41:30 +01:00
Elias Schneider
e46471cc2d release: 0.35.3 2025-02-25 20:34:37 +01:00
Elias Schneider
fde951b543 fix(ldap): sync error if LDAP user collides with an existing user 2025-02-25 20:34:13 +01:00
Kyle Mendell
01a9de0b04 fix: add option to manually select SMTP TLS method (#268)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-25 19:10:20 +01:00
Elias Schneider
a1131bca9a release: 0.35.2 2025-02-24 09:40:48 +01:00
Elias Schneider
9a167d4076 fix: delete profile picture if user gets deleted 2025-02-24 09:40:14 +01:00
Elias Schneider
887c5e462a fix: updating profile picture of other user updates own profile picture 2025-02-24 09:35:44 +01:00
83 changed files with 1277 additions and 567 deletions

View File

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

View File

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

View File

@@ -1 +1 @@
0.35.1 0.38.0

View File

@@ -1,3 +1,83 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.37.0...v) (2025-03-10)
### Features
* add env variable to disable update check ([31198fe](https://github.com/pocket-id/pocket-id/commit/31198feec2ae77dd6673c42b42002871ddd02d37))
### Bug Fixes
* redirection not correctly if signing in with email code ([e5ec264](https://github.com/pocket-id/pocket-id/commit/e5ec264bfd535752565bcc107099a9df5cb8aba7))
* typo in account settings ([#307](https://github.com/pocket-id/pocket-id/issues/307)) ([c822192](https://github.com/pocket-id/pocket-id/commit/c8221921245deb3008f655740d1a9460dcdab2fc))
## [](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) ## [](https://github.com/pocket-id/pocket-id/compare/v0.35.0...v) (2025-02-22)

View File

@@ -4,7 +4,10 @@ go 1.23.1
require ( require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.7.0 github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.15.0 github.com/go-co-op/gocron/v2 v2.15.0
@@ -30,6 +33,7 @@ require (
github.com/bytedance/sonic v1.12.8 // indirect github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.5 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect

View File

@@ -22,6 +22,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
@@ -32,6 +36,10 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=

View File

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

View File

@@ -94,6 +94,11 @@ type NotSignedInError struct{}
func (e *NotSignedInError) Error() string { return "You are not signed in" } func (e *NotSignedInError) Error() string { return "You are not signed in" }
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized } func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingAccessToken struct{}
func (e *MissingAccessToken) Error() string { return "Missing access token" }
func (e *MissingAccessToken) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{} type MissingPermissionError struct{}
func (e *MissingPermissionError) Error() string { func (e *MissingPermissionError) Error() string {
@@ -219,3 +224,10 @@ func (e *InvalidUUIDError) Error() string {
} }
type InvalidEmailError struct{} type InvalidEmailError struct{}
type OneTimeAccessDisabledError struct{}
func (e *OneTimeAccessDisabledError) Error() string {
return "One-time access is disabled"
}
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }

View File

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

View File

@@ -23,6 +23,7 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/oidc/token", oc.createTokensHandler) group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler) group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", oc.EndSessionHandler) group.POST("/oidc/end-session", oc.EndSessionHandler)
group.GET("/oidc/end-session", oc.EndSessionHandler) group.GET("/oidc/end-session", oc.EndSessionHandler)
@@ -111,7 +112,14 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
} }
func (oc *OidcController) userInfoHandler(c *gin.Context) { func (oc *OidcController) userInfoHandler(c *gin.Context) {
token := strings.Split(c.GetHeader("Authorization"), " ")[1] authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
if len(authHeaderSplit) != 2 {
c.Error(&common.MissingAccessToken{})
return
}
token := authHeaderSplit[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token) jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil { if err != nil {
c.Error(err) c.Error(err)

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ type AppConfigUpdateDto struct {
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"` SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"` SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls"` SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
LdapEnabled string `json:"ldapEnabled" binding:"required"` LdapEnabled string `json:"ldapEnabled" binding:"required"`
LdapUrl string `json:"ldapUrl"` LdapUrl string `json:"ldapUrl"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -94,7 +95,6 @@ func (s *LdapService) SyncGroups() error {
ldapGroupIDs := make(map[string]bool) ldapGroupIDs := make(map[string]bool)
for _, value := range result.Entries { for _, value := range result.Entries {
var usersToAddDto dto.UserGroupUpdateUsersDto
var membersUserId []string var membersUserId []string
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute) ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
@@ -112,7 +112,16 @@ func (s *LdapService) SyncGroups() error {
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0] singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
var databaseUser model.User var databaseUser model.User
s.db.Where("username = ?", singleMember).Where("ldap_id IS NOT NULL").First(&databaseUser) err := s.db.Where("username = ? AND ldap_id IS NOT NULL", singleMember).First(&databaseUser).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// The user collides with a non-LDAP user, so we skip it
continue
} else {
return err
}
}
membersUserId = append(membersUserId, databaseUser.ID) membersUserId = append(membersUserId, databaseUser.ID)
} }
@@ -123,22 +132,18 @@ func (s *LdapService) SyncGroups() error {
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute), LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
} }
usersToAddDto = dto.UserGroupUpdateUsersDto{
UserIDs: membersUserId,
}
if databaseGroup.ID == "" { if databaseGroup.ID == "" {
newGroup, err := s.groupService.Create(syncGroup) newGroup, err := s.groupService.Create(syncGroup)
if err != nil { if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
} else { } else {
if _, err = s.groupService.UpdateUsers(newGroup.ID, usersToAddDto); err != nil { if _, err = s.groupService.UpdateUsers(newGroup.ID, membersUserId); err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
} }
} }
} else { } else {
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true) _, err = s.groupService.Update(databaseGroup.ID, syncGroup, true)
_, err = s.groupService.UpdateUsers(databaseGroup.ID, usersToAddDto) _, err = s.groupService.UpdateUsers(databaseGroup.ID, membersUserId)
if err != nil { if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
return err return err
@@ -263,7 +268,7 @@ func (s *LdapService) SyncUsers() error {
// Delete users that no longer exist in LDAP // Delete users that no longer exist in LDAP
for _, user := range ldapUsersInDb { for _, user := range ldapUsersInDb {
if _, exists := ldapUserIDs[*user.LdapID]; !exists { if _, exists := ldapUserIDs[*user.LdapID]; !exists {
if err := s.db.Delete(&model.User{}, "ldap_id = ?", user.LdapID).Error; err != nil { if err := s.userService.DeleteUser(user.ID); err != nil {
log.Printf("Failed to delete user %s with: %v", user.Username, err) log.Printf("Failed to delete user %s with: %v", user.Username, err)
} else { } else {
log.Printf("Deleted user %s", user.Username) log.Printf("Deleted user %s", user.Username)

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package profilepicture
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"github.com/disintegration/imageorient"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
"golang.org/x/image/font" "golang.org/x/image/font"
@@ -18,7 +19,7 @@ const profilePictureSize = 300
// CreateProfilePicture resizes the profile picture to a square // CreateProfilePicture resizes the profile picture to a square
func CreateProfilePicture(file io.Reader) (*bytes.Buffer, error) { func CreateProfilePicture(file io.Reader) (*bytes.Buffer, error) {
img, err := imaging.Decode(file) img, _, err := imageorient.Decode(file)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err) return nil, fmt.Errorf("failed to decode image: %w", err)
} }

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
{{ define "base" -}} {{ define "base" -}}
One-Time Access Login Code
==================== ====================
Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes. Click the link below to sign in to {{ .AppName }} with a login code. This code expires in 15 minutes.
{{ .Data.Link }} {{ .Data.LoginLinkWithCode }}
Or visit {{ .Data.LoginLink }} and enter the the code "{{ .Data.Code }}".
{{ end -}} {{ end -}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const isUnauthenticatedOnlyPath = 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 isPublicPath = ['/authorize', '/health'].includes(event.url.pathname);
const isAdminPath = event.url.pathname.startsWith('/settings/admin'); const isAdminPath = event.url.pathname.startsWith('/settings/admin');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { version as currentVersion } from '$app/environment'; import { version as currentVersion } from '$app/environment';
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration'; import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
import axios, { AxiosError } from 'axios'; import axios from 'axios';
import APIService from './api-service'; import APIService from './api-service';
export default class AppConfigService extends APIService { export default class AppConfigService extends APIService {
@@ -57,18 +57,14 @@ export default class AppConfigService extends APIService {
async getVersionInformation() { async getVersionInformation() {
const response = await axios const response = await axios
.get('https://api.github.com/repos/pocket-id/pocket-id/releases/latest') .get('https://api.github.com/repos/pocket-id/pocket-id/releases/latest', {
timeout: 2000
})
.then((res) => res.data) .then((res) => res.data)
.catch((e) => { .catch(() => null);
console.error(
'Failed to fetch version information',
e instanceof AxiosError && e.response ? e.response.data.message : e
);
return null;
});
let newestVersion: string | null = null; let newestVersion: string | undefined;
let isUpToDate: boolean | null = null; let isUpToDate: boolean | undefined;
if (response) { if (response) {
newestVersion = response.tag_name.replace('v', ''); newestVersion = response.tag_name.replace('v', '');
isUpToDate = newestVersion === currentVersion; isUpToDate = newestVersion === currentVersion;

View File

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

View File

@@ -15,7 +15,7 @@ export type AllAppConfig = AppConfig & {
smtpFrom: string; smtpFrom: string;
smtpUser: string; smtpUser: string;
smtpPassword: string; smtpPassword: string;
smtpTls: boolean; smtpTls: 'none' | 'starttls' | 'tls';
smtpSkipCertVerify: boolean; smtpSkipCertVerify: boolean;
emailLoginNotificationEnabled: boolean; emailLoginNotificationEnabled: boolean;
// LDAP // LDAP
@@ -45,7 +45,7 @@ export type AppConfigRawResponse = {
}[]; }[];
export type AppVersionInformation = { export type AppVersionInformation = {
isUpToDate: boolean | null; isUpToDate?: boolean;
newestVersion: string | null; newestVersion?: string;
currentVersion: string; currentVersion: string;
}; };

View File

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

View File

@@ -83,7 +83,7 @@
{#if client == null} {#if client == null}
<p>Client not found</p> <p>Client not found</p>
{:else} {:else}
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}> <SignInWrapper showAlternativeSignInMethodButton>
<ClientProviderImages {client} {success} error={!!errorMessage} /> <ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if errorMessage} {#if errorMessage}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import { version as currentVersion } from '$app/environment';
import { env } from '$env/dynamic/private';
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import type { AppVersionInformation } from '$lib/types/application-configuration'; import type { AppVersionInformation } from '$lib/types/application-configuration';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
@@ -6,6 +8,14 @@ let versionInformation: AppVersionInformation;
let versionInformationLastUpdated: number; let versionInformationLastUpdated: number;
export const load: LayoutServerLoad = async () => { export const load: LayoutServerLoad = async () => {
if (env.UPDATE_CHECK_DISABLED === 'true') {
return {
versionInformation: {
currentVersion: currentVersion
} satisfies AppVersionInformation
};
}
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
// Cache the version information for 3 hours // Cache the version information for 3 hours

View File

@@ -11,15 +11,17 @@
import { startRegistration } from '@simplewebauthn/browser'; import { startRegistration } from '@simplewebauthn/browser';
import { LucideAlertTriangle } from 'lucide-svelte'; import { LucideAlertTriangle } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import AccountForm from './account-form.svelte';
import PasskeyList from './passkey-list.svelte';
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte'; 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'; import RenamePasskeyModal from './rename-passkey-modal.svelte';
let { data } = $props(); let { data } = $props();
let account = $state(data.account); let account = $state(data.account);
let passkeys = $state(data.passkeys); let passkeys = $state(data.passkeys);
let passkeyToRename: Passkey | null = $state(null); let passkeyToRename: Passkey | null = $state(null);
let showLoginCodeModal: boolean = $state(false);
const userService = new UserService(); const userService = new UserService();
const webauthnService = new WebAuthnService(); const webauthnService = new WebAuthnService();
@@ -75,7 +77,7 @@
<LucideAlertTriangle class="size-4" /> <LucideAlertTriangle class="size-4" />
<Alert.Title>Single Passkey Configured</Alert.Title> <Alert.Title>Single Passkey Configured</Alert.Title>
<Alert.Description <Alert.Description
>It is recommended to add more than one passkey to avoid loosing access to your account.</Alert.Description >It is recommended to add more than one passkey to avoid losing access to your account.</Alert.Description
> >
</Alert.Root> </Alert.Root>
{/if} {/if}
@@ -96,7 +98,11 @@
<Card.Root> <Card.Root>
<Card.Content class="pt-6"> <Card.Content class="pt-6">
<ProfilePictureSettings userId="me" isLdapUser={!!account.ldapId} callback={updateProfilePicture} /> <ProfilePictureSettings
userId="me"
isLdapUser={!!account.ldapId}
callback={updateProfilePicture}
/>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -109,7 +115,7 @@
Manage your passkeys that you can use to authenticate yourself. Manage your passkeys that you can use to authenticate yourself.
</Card.Description> </Card.Description>
</div> </div>
<Button size="sm" on:click={createPasskey}>Add Passkey</Button> <Button size="sm" class="ml-3" on:click={createPasskey}>Add Passkey</Button>
</div> </div>
</Card.Header> </Card.Header>
{#if passkeys.length != 0} {#if passkeys.length != 0}
@@ -118,7 +124,23 @@
</Card.Content> </Card.Content>
{/if} {/if}
</Card.Root> </Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Login Code</Card.Title>
<Card.Description class="mt-1">
Create a one-time login code to sign in from a different device without a passkey.
</Card.Description>
</div>
<Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}>Create</Button>
</div>
</Card.Header>
</Card.Root>
<RenamePasskeyModal <RenamePasskeyModal
bind:passkey={passkeyToRename} bind:passkey={passkeyToRename}
callback={async () => (passkeys = await webauthnService.listCredentials())} callback={async () => (passkeys = await webauthnService.listCredentials())}
/> />
<LoginCodeModal bind:show={showLoginCodeModal} />

View File

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

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
@@ -20,6 +22,11 @@
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true'; const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
const tlsOptions = {
none: 'None',
starttls: 'StartTLS',
tls: 'TLS'
};
let isSendingTestEmail = $state(false); let isSendingTestEmail = $state(false);
@@ -29,7 +36,7 @@
smtpUser: z.string(), smtpUser: z.string(),
smtpPassword: z.string(), smtpPassword: z.string(),
smtpFrom: z.string().email(), smtpFrom: z.string().email(),
smtpTls: z.boolean(), smtpTls: z.enum(['none', 'starttls', 'tls']),
smtpSkipCertVerify: z.boolean(), smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessEnabled: z.boolean(), emailOneTimeAccessEnabled: z.boolean(),
emailLoginNotificationEnabled: z.boolean() emailLoginNotificationEnabled: z.boolean()
@@ -96,12 +103,22 @@
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
<CheckboxWithLabel <div class="grid gap-2">
id="tls" <Label class="mb-0" for="smtp-tls">SMTP TLS Option</Label>
label="TLS" <Select.Root
description="Enable TLS for the SMTP connection." selected={{ value: $inputs.smtpTls.value, label: tlsOptions[$inputs.smtpTls.value] }}
bind:checked={$inputs.smtpTls.value} onSelectedChange={(v) => ($inputs.smtpTls.value = v!.value)}
/> >
<Select.Trigger>
<Select.Value placeholder="Email TLS Option" />
</Select.Trigger>
<Select.Content>
<Select.Item value="none" label="None" />
<Select.Item value="starttls" label="StartTLS" />
<Select.Item value="tls" label="TLS" />
</Select.Content>
</Select.Root>
</div>
<CheckboxWithLabel <CheckboxWithLabel
id="skip-cert-verify" id="skip-cert-verify"
label="Skip Certificate Verification" label="Skip Certificate Verification"
@@ -118,9 +135,9 @@
bind:checked={$inputs.emailLoginNotificationEnabled.value} bind:checked={$inputs.emailLoginNotificationEnabled.value}
/> />
<CheckboxWithLabel <CheckboxWithLabel
id="email-one-time-access" id="email-login"
label="Email One Time Access" label="Email Login"
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." 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} bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/> />
</div> </div>

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte'; import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import OidcService from '$lib/services/oidc-service'; import OidcService from '$lib/services/oidc-service';
import UserGroupService from '$lib/services/user-group-service';
import clientSecretStore from '$lib/stores/client-secret-store'; import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type'; import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -15,8 +16,6 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte'; import OidcForm from '../oidc-client-form.svelte';
import UserGroupSelection from '../user-group-selection.svelte';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
let { data } = $props(); let { data } = $props();
let client = $state({ let client = $state({
@@ -26,7 +25,6 @@
let showAllDetails = $state(false); let showAllDetails = $state(false);
const oidcService = new OidcService(); const oidcService = new OidcService();
const userGroupService = new UserGroupService();
const setupDetails = $state({ const setupDetails = $state({
'Authorization URL': `https://${$page.url.hostname}/authorize`, 'Authorization URL': `https://${$page.url.hostname}/authorize`,
@@ -177,9 +175,7 @@
title="Allowed User Groups" title="Allowed User Groups"
description="Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client." description="Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client."
> >
{#await userGroupService.list() then groups} <UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
<UserGroupSelection {groups} bind:selectedGroupIds={client.allowedUserGroupIds} />
{/await}
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button> <Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button>
</div> </div>

View File

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

View File

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

View File

@@ -1,9 +1,18 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import UserGroupService from '$lib/services/user-group-service'; import UserGroupService from '$lib/services/user-group-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const userGroupService = new UserGroupService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const userGroupService = new UserGroupService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const userGroups = await userGroupService.list();
return userGroups; const userGroupsRequestOptions: SearchPaginationSortRequest = {
sort: {
column: 'friendlyName',
direction: 'asc'
},
};
const userGroups = await userGroupService.list(userGroupsRequestOptions);
return {userGroups, userGroupsRequestOptions};
}; };

View File

@@ -13,7 +13,8 @@
import UserGroupList from './user-group-list.svelte'; import UserGroupList from './user-group-list.svelte';
let { data } = $props(); let { data } = $props();
let userGroups: Paginated<UserGroupWithUserCount> = $state(data); let userGroups = $state(data.userGroups);
let userGroupsRequestOptions = $state(data.userGroupsRequestOptions);
let expandAddUserGroup = $state(false); let expandAddUserGroup = $state(false);
const userGroupService = new UserGroupService(); const userGroupService = new UserGroupService();
@@ -68,6 +69,6 @@
<Card.Title>Manage User Groups</Card.Title> <Card.Title>Manage User Groups</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<UserGroupList {userGroups} /> <UserGroupList {userGroups} requestOptions={userGroupsRequestOptions} />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -6,14 +6,13 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import CustomClaimService from '$lib/services/custom-claim-service'; import CustomClaimService from '$lib/services/custom-claim-service';
import UserGroupService from '$lib/services/user-group-service'; import UserGroupService from '$lib/services/user-group-service';
import UserService from '$lib/services/user-service'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type'; import type { UserGroupCreate } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from 'lucide-svelte'; import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import UserGroupForm from '../user-group-form.svelte'; import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte'; import UserSelection from '../user-selection.svelte';
import appConfigStore from '$lib/stores/application-configuration-store';
let { data } = $props(); let { data } = $props();
let userGroup = $state({ let userGroup = $state({
@@ -22,7 +21,6 @@
}); });
const userGroupService = new UserGroupService(); const userGroupService = new UserGroupService();
const userService = new UserService();
const customClaimService = new CustomClaimService(); const customClaimService = new CustomClaimService();
async function updateUserGroup(updatedUserGroup: UserGroupCreate) { async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
@@ -86,16 +84,14 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
{#await userService.list() then users}
<UserSelection <UserSelection
{users}
bind:selectedUserIds={userGroup.userIds} bind:selectedUserIds={userGroup.userIds}
selectionDisabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled} selectionDisabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
/> />
{/await}
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled} on:click={() => updateUserGroupUsers(userGroup.userIds)} <Button
>Save</Button disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
on:click={() => updateUserGroupUsers(userGroup.userIds)}>Save</Button
> >
</div> </div>
</Card.Content> </Card.Content>

View File

@@ -14,11 +14,13 @@
import Ellipsis from 'lucide-svelte/icons/ellipsis'; import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
let { userGroups: initialUserGroups }: { userGroups: Paginated<UserGroupWithUserCount> } = let {
$props(); userGroups,
requestOptions
let userGroups = $state<Paginated<UserGroupWithUserCount>>(initialUserGroups); }: {
let requestOptions: SearchPaginationSortRequest | undefined = $state(); userGroups: Paginated<UserGroupWithUserCount>;
requestOptions: SearchPaginationSortRequest;
} = $props();
const userGroupService = new UserGroupService(); const userGroupService = new UserGroupService();

View File

@@ -2,32 +2,48 @@
import AdvancedTable from '$lib/components/advanced-table.svelte'; import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { Paginated } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type'; import type { User } from '$lib/types/user.type';
import { onMount } from 'svelte';
let { let {
users: initialUsers,
selectionDisabled = false, selectionDisabled = false,
selectedUserIds = $bindable() selectedUserIds = $bindable()
}: { users: Paginated<User>; selectionDisabled?: boolean; selectedUserIds: string[] } = $props(); }: {
selectionDisabled?: boolean;
selectedUserIds: string[];
} = $props();
const userService = new UserService(); const userService = new UserService();
let users = $state(initialUsers); let users: Paginated<User> | undefined = $state();
let requestOptions: SearchPaginationSortRequest = $state({
sort: {
column: 'firstName',
direction: 'asc'
}
});
onMount(async () => {
users = await userService.list(requestOptions);
});
</script> </script>
<AdvancedTable {#if users}
<AdvancedTable
items={users} items={users}
onRefresh={async (o) => (users = await userService.list(o))} onRefresh={async (o) => (users = await userService.list(o))}
{requestOptions}
columns={[ columns={[
{ label: 'Name', sortColumn: 'name' }, { label: 'Name', sortColumn: 'firstName' },
{ label: 'Email', sortColumn: 'email' } { label: 'Email', sortColumn: 'email' }
]} ]}
bind:selectedIds={selectedUserIds} bind:selectedIds={selectedUserIds}
{selectionDisabled} {selectionDisabled}
> >
{#snippet rows({ item })} {#snippet rows({ item })}
<Table.Cell>{item.firstName} {item.lastName}</Table.Cell> <Table.Cell>{item.firstName} {item.lastName}</Table.Cell>
<Table.Cell>{item.email}</Table.Cell> <Table.Cell>{item.email}</Table.Cell>
{/snippet} {/snippet}
</AdvancedTable> </AdvancedTable>
{/if}

View File

@@ -1,9 +1,18 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const users = await userService.list();
return users; const usersRequestOptions: SearchPaginationSortRequest = {
sort: {
column: 'firstName',
direction: 'asc'
}
};
const users = await userService.list(usersRequestOptions);
return {users, usersRequestOptions};
}; };

View File

@@ -3,8 +3,7 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { Paginated } from '$lib/types/pagination.type'; import type { UserCreate } from '$lib/types/user.type';
import type { User, UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte'; import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -13,7 +12,9 @@
import UserList from './user-list.svelte'; import UserList from './user-list.svelte';
let { data } = $props(); let { data } = $props();
let users: Paginated<User> = $state(data); let users = $state(data.users);
let usersRequestOptions = $state(data.usersRequestOptions);
let expandAddUser = $state(false); let expandAddUser = $state(false);
const userService = new UserService(); const userService = new UserService();
@@ -28,7 +29,7 @@
success = false; success = false;
}); });
users = await userService.list(); users = await userService.list(usersRequestOptions);
return success; return success;
} }
</script> </script>
@@ -67,6 +68,6 @@
<Card.Title>Manage Users</Card.Title> <Card.Title>Manage Users</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<UserList {users} /> <UserList {users} requestOptions={usersRequestOptions} />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

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

View File

@@ -5,8 +5,10 @@
import Badge from '$lib/components/ui/badge/badge.svelte'; import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import CustomClaimService from '$lib/services/custom-claim-service'; import CustomClaimService from '$lib/services/custom-claim-service';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from 'lucide-svelte'; import { LucideChevronLeft } from 'lucide-svelte';
@@ -14,11 +16,23 @@
import UserForm from '../user-form.svelte'; import UserForm from '../user-form.svelte';
let { data } = $props(); let { data } = $props();
let user = $state(data); let user = $state({
...data.user,
userGroupIds: data.user.userGroups.map((g) => g.id)
});
const userService = new UserService(); const userService = new UserService();
const customClaimService = new CustomClaimService(); const customClaimService = new CustomClaimService();
async function updateUserGroups(userIds: string[]) {
await userService
.updateUserGroups(user.id, userIds)
.then(() => toast.success('User groups updated successfully'))
.catch((e) => {
axiosErrorToast(e);
});
}
async function updateUser(updatedUser: UserCreate) { async function updateUser(updatedUser: UserCreate) {
let success = true; let success = true;
await userService await userService
@@ -80,6 +94,24 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<CollapsibleCard
id="user-groups"
title="User Groups"
description="Manage which groups this user belongs to."
>
<UserGroupSelection
bind:selectedGroupIds={user.userGroupIds}
selectionDisabled={!!user.ldapId && $appConfigStore.ldapEnabled}
/>
<div class="mt-5 flex justify-end">
<Button
on:click={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">Save</Button
>
</div>
</CollapsibleCard>
<CollapsibleCard <CollapsibleCard
id="user-custom-claims" id="user-custom-claims"
title="Custom Claims" title="Custom Claims"
@@ -87,6 +119,6 @@
> >
<CustomClaimsInput bind:customClaims={user.customClaims} /> <CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button> <Button on:click={updateCustomClaims} type="submit">Save</Button>
</div> </div>
</CollapsibleCard> </CollapsibleCard>

View File

@@ -14,10 +14,12 @@
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte'; import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
import Ellipsis from 'lucide-svelte/icons/ellipsis'; import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './one-time-link-modal.svelte'; import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte';
let { users = $bindable() }: { users: Paginated<User> } = $props(); let {
let requestOptions: SearchPaginationSortRequest | undefined = $state(); users = $bindable(),
requestOptions
}: { users: Paginated<User>; requestOptions: SearchPaginationSortRequest } = $props();
let userIdToCreateOneTimeLink: string | null = $state(null); let userIdToCreateOneTimeLink: string | null = $state(null);
@@ -80,7 +82,7 @@
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content align="end"> <DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)} <DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item ><LucideLink class="mr-2 h-4 w-4" />Login Code</DropdownMenu.Item
> >
<DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)} <DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item ><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item

View File

@@ -1,16 +1,16 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import AuditLogService from '$lib/services/audit-log-service'; import AuditLogService from '$lib/services/audit-log-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const auditLogService = new AuditLogService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const auditLogService = new AuditLogService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const auditLogs = await auditLogService.list({ const auditLogsRequestOptions: SearchPaginationSortRequest = {
sort: { sort: {
column: 'createdAt', column: 'createdAt',
direction: 'desc' direction: 'desc'
} }
});
return {
auditLogs
}; };
const auditLogs = await auditLogService.list(auditLogsRequestOptions);
return { auditLogs, auditLogsRequestOptions };
}; };

View File

@@ -3,6 +3,8 @@
import AuditLogList from './audit-log-list.svelte'; import AuditLogList from './audit-log-list.svelte';
let { data } = $props(); let { data } = $props();
let { auditLogs } = data;
let auditLogsRequestOptions = $state(data.auditLogsRequestOptions);
</script> </script>
<svelte:head> <svelte:head>
@@ -17,6 +19,6 @@
> >
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<AuditLogList auditLogs={data.auditLogs} /> <AuditLogList auditLogs={data.auditLogs} requestOptions={auditLogsRequestOptions} />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -4,10 +4,12 @@
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import AuditLogService from '$lib/services/audit-log-service'; import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLog } from '$lib/types/audit-log.type'; import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
let { auditLogs: initialAuditLog }: { auditLogs: Paginated<AuditLog> } = $props(); let {
let auditLogs = $state<Paginated<AuditLog>>(initialAuditLog); auditLogs,
requestOptions
}: { auditLogs: Paginated<AuditLog>; requestOptions: SearchPaginationSortRequest } = $props();
const auditLogService = new AuditLogService(); const auditLogService = new AuditLogService();
@@ -22,8 +24,8 @@
<AdvancedTable <AdvancedTable
items={auditLogs} items={auditLogs}
{requestOptions}
onRefresh={async (options) => (auditLogs = await auditLogService.list(options))} onRefresh={async (options) => (auditLogs = await auditLogService.list(options))}
defaultSort={{ column: 'createdAt', direction: 'desc' }}
columns={[ columns={[
{ label: 'Time', sortColumn: 'createdAt' }, { label: 'Time', sortColumn: 'createdAt' },
{ label: 'Event', sortColumn: 'event' }, { label: 'Event', sortColumn: 'event' },

View File

@@ -69,3 +69,20 @@ test('Delete passkey from account', async ({ page }) => {
await expect(page.getByRole('status')).toHaveText('Passkey deleted successfully'); await expect(page.getByRole('status')).toHaveText('Passkey deleted successfully');
}); });
test('Generate own one time access token as non admin', async ({ page, context }) => {
await context.clearCookies();
await page.goto('/login');
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Authenticate' }).click();
await page.waitForURL('/settings/account');
await page.getByRole('button', { name: 'Create' }).click();
const link = await page.getByTestId('login-code-link').textContent();
await context.clearCookies();
await page.goto(link!);
await page.waitForURL('/settings/account');
});

View File

@@ -32,7 +32,7 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('SMTP Password').fill('password'); await page.getByLabel('SMTP Password').fill('password');
await page.getByLabel('SMTP From').fill('test@gmail.com'); await page.getByLabel('SMTP From').fill('test@gmail.com');
await page.getByLabel('Email Login Notification').click(); await page.getByLabel('Email Login Notification').click();
await page.getByLabel('Email One Time Access').click(); await page.getByLabel('Email Login', { exact: true }).click();
await page.getByRole('button', { name: 'Save' }).nth(1).click(); await page.getByRole('button', { name: 'Save' }).nth(1).click();
@@ -46,7 +46,7 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('SMTP Password')).toHaveValue('password'); await expect(page.getByLabel('SMTP Password')).toHaveValue('password');
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com'); await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
await expect(page.getByLabel('Email Login Notification')).toBeChecked(); await expect(page.getByLabel('Email Login Notification')).toBeChecked();
await expect(page.getByLabel('Email One Time Access')).toBeChecked(); await expect(page.getByLabel('Email Login', { exact: true })).toBeChecked();
}); });
test('Update LDAP configuration', async ({ page }) => { test('Update LDAP configuration', async ({ page }) => {

View File

@@ -1,22 +1,47 @@
import test, { expect } from '@playwright/test'; import test, { expect } from '@playwright/test';
import { oneTimeAccessTokens } from './data'; import { oneTimeAccessTokens } from './data';
import { cleanupBackend } from './utils/cleanup.util';
test.beforeEach(cleanupBackend);
// Disable authentication for these tests // Disable authentication for these tests
test.use({ storageState: { cookies: [], origins: [] } }); test.use({ storageState: { cookies: [], origins: [] } });
test('Sign in with one time access token', async ({ page }) => { test('Sign in with login code', async ({ page }) => {
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0]; const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
await page.goto(`/login/${token.token}`); await page.goto(`/lc/${token.token}`);
await page.getByRole('button', { name: 'Continue' }).click();
await page.waitForURL('/settings/account'); await page.waitForURL('/settings/account');
}); });
test('Sign in with expired one time access token fails', async ({ page }) => { test('Sign in with login code entered manually', async ({ page }) => {
const token = oneTimeAccessTokens.filter((t) => t.expired)[0]; const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
await page.goto(`/login/${token.token}`); await page.goto('/lc');
await page.getByPlaceholder('Code').first().fill(token.token);
await page.getByText('Submit').first().click();
await page.waitForURL('/settings/account');
});
test('Sign in with expired login code fails', async ({ page }) => {
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
await page.goto(`/lc/${token.token}`);
await expect(page.getByRole('paragraph')).toHaveText(
'Token is invalid or expired. Please try again.'
);
});
test('Sign in with login code entered manually fails', async ({ page }) => {
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
await page.goto('/lc');
await page.getByPlaceholder('Code').first().fill(token.token);
await page.getByText('Submit').first().click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('paragraph')).toHaveText( await expect(page.getByRole('paragraph')).toHaveText(
'Token is invalid or expired. Please try again.' 'Token is invalid or expired. Please try again.'
); );

View File

@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test'; import test, { expect } from '@playwright/test';
import { users } from './data'; import { userGroups, users } from './data';
import { cleanupBackend } from './utils/cleanup.util'; import { cleanupBackend } from './utils/cleanup.util';
test.beforeEach(cleanupBackend); test.beforeEach(cleanupBackend);
@@ -58,14 +58,14 @@ test('Create one time access token', async ({ page }) => {
.getByRole('button') .getByRole('button')
.click(); .click();
await page.getByRole('menuitem', { name: 'One-time link' }).click(); await page.getByRole('menuitem', { name: 'Login Code' }).click();
await page.getByLabel('One Time Link').getByRole('combobox').click(); await page.getByLabel('Login Code').getByRole('combobox').click();
await page.getByRole('option', { name: '12 hours' }).click(); await page.getByRole('option', { name: '12 hours' }).click();
await page.getByRole('button', { name: 'Generate Link' }).click(); await page.getByRole('button', { name: 'Generate Code' }).click();
await expect(page.getByRole('textbox', { name: 'One Time Link' })).toHaveValue( await expect(page.getByRole('textbox', { name: 'Login Code' })).toHaveValue(
/http:\/\/localhost\/login\/.*/ /http:\/\/localhost\/lc\/.*/
); );
}); });
@@ -142,7 +142,7 @@ test('Update user fails with already taken username', async ({ page }) => {
test('Update user custom claims', async ({ page }) => { test('Update user custom claims', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`); await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.getByRole('button', { name: 'Expand card' }).click(); await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
// Add two custom claims // Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click(); await page.getByRole('button', { name: 'Add custom claim' }).click();
@@ -178,3 +178,26 @@ test('Update user custom claims', async ({ page }) => {
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2'); await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value'); await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
}); });
test('Update user group assignments', async ({ page }) => {
const user = users.craig;
await page.goto(`/settings/admin/users/${user.id}`);
page.getByRole('button', { name: 'Expand card' }).first().click();
await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click();
await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.getByRole('status')).toHaveText('User groups updated successfully');
await page.reload();
await expect(
page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox')
).toHaveAttribute('data-state', 'checked');
await expect(
page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox')
).toHaveAttribute('data-state', 'unchecked');
});

View File

@@ -100,7 +100,7 @@ fi
echo "=================================================" echo "================================================="
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
echo "A one-time access token valid for 1 hour has been created for \"$USER_IDENTIFIER\"." echo "A one-time access token valid for 1 hour has been created for \"$USER_IDENTIFIER\"."
echo "Use the following URL to sign in once: ${PUBLIC_APP_URL:=https://<your-pocket-id-domain>}/login/$SECRET_TOKEN" echo "Use the following URL to sign in once: ${PUBLIC_APP_URL:=https://<your-pocket-id-domain>}/lc/$SECRET_TOKEN"
else else
echo "Error creating access token." echo "Error creating access token."
exit 1 exit 1