mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-14 17:23:44 +03:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d55c7c393 | ||
|
|
0f14a93e1d | ||
|
|
37b24bed91 | ||
|
|
66090f36a8 | ||
|
|
ff34e3b925 | ||
|
|
91f254c7bb | ||
|
|
85db96b0ef | ||
|
|
12d60fea23 | ||
|
|
2d733fc79f | ||
|
|
a421d01e0c | ||
|
|
1026ee4f5b | ||
|
|
cddfe8fa4c | ||
|
|
ef25f6b6b8 | ||
|
|
1652cc65f3 | ||
|
|
4bafee4f58 |
@@ -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
|
||||||
|
|||||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,3 +1,39 @@
|
|||||||
|
## [](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)
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.2...v) (2025-02-25)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -27,13 +27,16 @@ 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/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
|
||||||
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
||||||
@@ -46,6 +49,23 @@ type UserController struct {
|
|||||||
appConfigService *service.AppConfigService
|
appConfigService *service.AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
groups, err := uc.userService.GetUserGroups(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupsDto []dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStructList(groups, &groupsDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, groupsDto)
|
||||||
|
}
|
||||||
|
|
||||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
@@ -315,3 +335,25 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||||
|
var input dto.UserUpdateUserGroupDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type UserDto struct {
|
|||||||
LastName string `json:"lastName"`
|
LastName string `json:"lastName"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
|
UserGroups []UserGroupDto `json:"userGroups"`
|
||||||
LdapID *string `json:"ldapId"`
|
LdapID *string `json:"ldapId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,3 +32,7 @@ type OneTimeAccessEmailDto struct {
|
|||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
RedirectPath string `json:"redirectPath"`
|
RedirectPath string `json:"redirectPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserUpdateUserGroupDto struct {
|
||||||
|
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,18 +110,14 @@ 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
|
// Connect to the SMTP server based on TLS setting
|
||||||
switch srv.appConfigService.DbConfig.SmtpTls.Value {
|
switch srv.appConfigService.DbConfig.SmtpTls.Value {
|
||||||
case "none":
|
case "none":
|
||||||
client, err = srv.connectToSmtpServer(smtpAddress)
|
client, err = smtp.Dial(smtpAddress)
|
||||||
case "tls":
|
case "tls":
|
||||||
client, err = srv.connectToSmtpServerUsingImplicitTLS(
|
client, err = smtp.DialTLS(smtpAddress, tlsConfig)
|
||||||
smtpAddress,
|
|
||||||
tlsConfig,
|
|
||||||
)
|
|
||||||
case "starttls":
|
case "starttls":
|
||||||
client, err = srv.connectToSmtpServerUsingStartTLS(
|
client, err = smtp.DialStartTLS(
|
||||||
smtpAddress,
|
smtpAddress,
|
||||||
tlsConfig,
|
tlsConfig,
|
||||||
)
|
)
|
||||||
@@ -136,87 +128,39 @@ func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
|
|||||||
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 {
|
||||||
@@ -228,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -269,6 +278,33 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAg
|
|||||||
return oneTimeAccessToken.User, accessToken, nil
|
return oneTimeAccessToken.User, accessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserService) UpdateUserGroups(id string, userGroupIds []string) (user model.User, err error) {
|
||||||
|
user, err = s.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the groups based on userGroupIds
|
||||||
|
var groups []model.UserGroup
|
||||||
|
if len(userGroupIds) > 0 {
|
||||||
|
if err := s.db.Where("id IN (?)", userGroupIds).Find(&groups).Error; err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the current groups with the new set of groups
|
||||||
|
if err := s.db.Model(&user).Association("UserGroups").Replace(groups); err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated user
|
||||||
|
if err := s.db.Save(&user).Error; err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
||||||
var userCount int64
|
var userCount int64
|
||||||
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
33
backend/internal/utils/systemd/sdnotify.go
Normal file
33
backend/internal/utils/systemd/sdnotify.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package systemd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SdNotifyReady sends a message to the systemd daemon to notify that service is ready to operate.
|
||||||
|
// It is common to ignore the error.
|
||||||
|
func SdNotifyReady() error {
|
||||||
|
socketAddr := &net.UnixAddr{
|
||||||
|
Name: os.Getenv("NOTIFY_SOCKET"),
|
||||||
|
Net: "unixgram",
|
||||||
|
}
|
||||||
|
|
||||||
|
if socketAddr.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err = conn.Write([]byte("READY=1")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.35.3",
|
"version": "0.36.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
import type { OidcClient } from '$lib/types/oidc.type';
|
|
||||||
import type { Paginated } from '$lib/types/pagination.type';
|
import type { Paginated } from '$lib/types/pagination.type';
|
||||||
import type { UserGroup } from '$lib/types/user-group.type';
|
import type { UserGroup } from '$lib/types/user-group.type';
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CustomClaim } from './custom-claim.type';
|
import type { CustomClaim } from './custom-claim.type';
|
||||||
|
import type { UserGroup } from './user-group.type';
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7,6 +8,7 @@ export type User = {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
userGroups: UserGroup[];
|
||||||
customClaims: CustomClaim[];
|
customClaims: CustomClaim[];
|
||||||
ldapId?: string;
|
ldapId?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
||||||
import OIDCService from '$lib/services/oidc-service';
|
import OIDCService from '$lib/services/oidc-service';
|
||||||
|
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies }) => {
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
const oidcService = new OIDCService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
const oidcService = new OIDCService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||||
const clients = await oidcService.listClients();
|
|
||||||
|
// Create request options with default sorting
|
||||||
|
const requestOptions: SearchPaginationSortRequest = {
|
||||||
|
sort: {
|
||||||
|
column: 'name',
|
||||||
|
direction: 'asc'
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clients = await oidcService.listClients(requestOptions);
|
||||||
|
|
||||||
return clients;
|
return clients;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<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 UserGroupService from '$lib/services/user-group-service';
|
||||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||||
@@ -15,8 +17,6 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import OidcForm from '../oidc-client-form.svelte';
|
import OidcForm from '../oidc-client-form.svelte';
|
||||||
import UserGroupSelection from '../user-group-selection.svelte';
|
|
||||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let client = $state({
|
let client = $state({
|
||||||
|
|||||||
@@ -11,10 +11,20 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import OneTimeLinkModal from './client-secret.svelte';
|
import OneTimeLinkModal from './client-secret.svelte';
|
||||||
|
|
||||||
let { clients: initialClients }: { clients: Paginated<OidcClient> } = $props();
|
let {
|
||||||
|
clients: initialClients
|
||||||
|
}: {
|
||||||
|
clients: Paginated<OidcClient>;
|
||||||
|
} = $props();
|
||||||
let clients = $state<Paginated<OidcClient>>(initialClients);
|
let clients = $state<Paginated<OidcClient>>(initialClients);
|
||||||
let oneTimeLink = $state<string | null>(null);
|
let oneTimeLink = $state<string | null>(null);
|
||||||
let requestOptions: SearchPaginationSortRequest | undefined = $state();
|
let requestOptions: SearchPaginationSortRequest | undefined = $state({
|
||||||
|
sort: { column: 'name', direction: 'asc' },
|
||||||
|
pagination: {
|
||||||
|
page: initialClients.pagination.currentPage,
|
||||||
|
limit: initialClients.pagination.itemsPerPage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
clients = initialClients;
|
clients = initialClients;
|
||||||
@@ -46,6 +56,7 @@
|
|||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
items={clients}
|
items={clients}
|
||||||
{requestOptions}
|
{requestOptions}
|
||||||
|
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||||
onRefresh={async (o) => (clients = await oidcService.listClients(o))}
|
onRefresh={async (o) => (clients = await oidcService.listClients(o))}
|
||||||
columns={[
|
columns={[
|
||||||
{ label: 'Logo' },
|
{ label: 'Logo' },
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
|
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies }) => {
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
const userGroupService = new UserGroupService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
const userGroupService = new UserGroupService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||||
const userGroups = await userGroupService.list();
|
|
||||||
|
// Create request options with default sorting
|
||||||
|
const requestOptions: SearchPaginationSortRequest = {
|
||||||
|
sort: {
|
||||||
|
column: 'friendlyName',
|
||||||
|
direction: 'asc'
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const userGroups = await userGroupService.list(requestOptions);
|
||||||
return userGroups;
|
return userGroups;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,13 @@
|
|||||||
$props();
|
$props();
|
||||||
|
|
||||||
let userGroups = $state<Paginated<UserGroupWithUserCount>>(initialUserGroups);
|
let userGroups = $state<Paginated<UserGroupWithUserCount>>(initialUserGroups);
|
||||||
let requestOptions: SearchPaginationSortRequest | undefined = $state();
|
let requestOptions: SearchPaginationSortRequest | undefined = $state({
|
||||||
|
sort: { column: 'friendlyName', direction: 'asc' },
|
||||||
|
pagination: {
|
||||||
|
page: initialUserGroups.pagination.currentPage,
|
||||||
|
limit: initialUserGroups.pagination.itemsPerPage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
const userGroupService = new UserGroupService();
|
||||||
|
|
||||||
@@ -47,6 +53,7 @@
|
|||||||
items={userGroups}
|
items={userGroups}
|
||||||
onRefresh={async (o) => (userGroups = await userGroupService.list(o))}
|
onRefresh={async (o) => (userGroups = await userGroupService.list(o))}
|
||||||
{requestOptions}
|
{requestOptions}
|
||||||
|
defaultSort={{ column: 'friendlyName', direction: 'asc' }}
|
||||||
columns={[
|
columns={[
|
||||||
{ label: 'Friendly Name', sortColumn: 'friendlyName' },
|
{ label: 'Friendly Name', sortColumn: 'friendlyName' },
|
||||||
{ label: 'Name', sortColumn: 'name' },
|
{ label: 'Name', sortColumn: 'name' },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import type { Paginated } from '$lib/types/pagination.type';
|
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
import type { User } from '$lib/types/user.type';
|
import type { User } from '$lib/types/user.type';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -10,15 +10,24 @@
|
|||||||
selectionDisabled = false,
|
selectionDisabled = false,
|
||||||
selectedUserIds = $bindable()
|
selectedUserIds = $bindable()
|
||||||
}: { users: Paginated<User>; selectionDisabled?: boolean; selectedUserIds: string[] } = $props();
|
}: { users: Paginated<User>; selectionDisabled?: boolean; selectedUserIds: string[] } = $props();
|
||||||
|
let requestOptions: SearchPaginationSortRequest | undefined = $state({
|
||||||
|
sort: { column: 'friendlyName', direction: 'asc' },
|
||||||
|
pagination: {
|
||||||
|
page: initialUsers.pagination.currentPage,
|
||||||
|
limit: initialUsers.pagination.itemsPerPage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let users = $state<Paginated<User>>(initialUsers);
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
let users = $state(initialUsers);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
items={users}
|
items={users}
|
||||||
onRefresh={async (o) => (users = await userService.list(o))}
|
onRefresh={async (o) => (users = await userService.list(o))}
|
||||||
|
{requestOptions}
|
||||||
|
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||||
columns={[
|
columns={[
|
||||||
{ label: 'Name', sortColumn: 'name' },
|
{ label: 'Name', sortColumn: 'name' },
|
||||||
{ label: 'Email', sortColumn: 'email' }
|
{ label: 'Email', sortColumn: 'email' }
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
|
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies }) => {
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||||
const users = await userService.list();
|
|
||||||
|
// Create request options with default sorting
|
||||||
|
const requestOptions: SearchPaginationSortRequest = {
|
||||||
|
sort: {
|
||||||
|
column: 'firstName',
|
||||||
|
direction: 'asc'
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const users = await userService.list(requestOptions);
|
||||||
return users;
|
return users;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,18 +6,34 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||||
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { UserCreate } from '$lib/types/user.type';
|
import type { UserCreate } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideChevronLeft } from 'lucide-svelte';
|
import { LucideChevronLeft } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
|
||||||
import UserForm from '../user-form.svelte';
|
import UserForm from '../user-form.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let user = $state(data);
|
let user = $state({
|
||||||
|
...data.user,
|
||||||
|
userGroupIds: data.user.userGroups.map((g) => g.id)
|
||||||
|
});
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
const customClaimService = new CustomClaimService();
|
const customClaimService = new CustomClaimService();
|
||||||
|
const userGroupService = new UserGroupService();
|
||||||
|
|
||||||
|
async function updateUserGroups(userIds: string[]) {
|
||||||
|
await userService
|
||||||
|
.updateUserGroups(user.id, userIds)
|
||||||
|
.then(() => toast.success('User groups updated successfully'))
|
||||||
|
.catch((e) => {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function updateUser(updatedUser: UserCreate) {
|
async function updateUser(updatedUser: UserCreate) {
|
||||||
let success = true;
|
let success = true;
|
||||||
@@ -80,6 +96,28 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
<CollapsibleCard
|
||||||
|
id="user-groups"
|
||||||
|
title="User Groups"
|
||||||
|
description="Manage which groups this user belongs to."
|
||||||
|
>
|
||||||
|
{#await userGroupService.list() then groups}
|
||||||
|
<UserGroupSelection
|
||||||
|
{groups}
|
||||||
|
bind:selectedGroupIds={user.userGroupIds}
|
||||||
|
selectionDisabled={!!user.ldapId && $appConfigStore.ldapEnabled}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
|
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button
|
||||||
|
on:click={() => updateUserGroups(user.userGroupIds)}
|
||||||
|
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
|
||||||
|
type="submit">Save</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</CollapsibleCard>
|
||||||
|
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
id="user-custom-claims"
|
id="user-custom-claims"
|
||||||
title="Custom Claims"
|
title="Custom Claims"
|
||||||
@@ -87,6 +125,6 @@
|
|||||||
>
|
>
|
||||||
<CustomClaimsInput bind:customClaims={user.customClaims} />
|
<CustomClaimsInput bind:customClaims={user.customClaims} />
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
<Button on:click={updateCustomClaims} type="submit">Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
|
|||||||
@@ -17,10 +17,17 @@
|
|||||||
import OneTimeLinkModal from './one-time-link-modal.svelte';
|
import OneTimeLinkModal from './one-time-link-modal.svelte';
|
||||||
|
|
||||||
let { users = $bindable() }: { users: Paginated<User> } = $props();
|
let { users = $bindable() }: { users: Paginated<User> } = $props();
|
||||||
let requestOptions: SearchPaginationSortRequest | undefined = $state();
|
|
||||||
|
|
||||||
let userIdToCreateOneTimeLink: string | null = $state(null);
|
let userIdToCreateOneTimeLink: string | null = $state(null);
|
||||||
|
|
||||||
|
let requestOptions: SearchPaginationSortRequest | undefined = $state({
|
||||||
|
sort: { column: 'firstName', direction: 'asc' },
|
||||||
|
pagination: {
|
||||||
|
page: users.pagination.currentPage,
|
||||||
|
limit: users.pagination.itemsPerPage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
async function deleteUser(user: User) {
|
async function deleteUser(user: User) {
|
||||||
@@ -47,6 +54,7 @@
|
|||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
items={users}
|
items={users}
|
||||||
{requestOptions}
|
{requestOptions}
|
||||||
|
defaultSort={{ column: 'firstName', direction: 'asc' }}
|
||||||
onRefresh={async (options) => (users = await userService.list(options))}
|
onRefresh={async (options) => (users = await userService.list(options))}
|
||||||
columns={[
|
columns={[
|
||||||
{ label: 'First name', sortColumn: 'firstName' },
|
{ label: 'First name', sortColumn: 'firstName' },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import test, { expect } from '@playwright/test';
|
import test, { expect } from '@playwright/test';
|
||||||
import { users } from './data';
|
import { users, userGroups } from './data';
|
||||||
import { cleanupBackend } from './utils/cleanup.util';
|
import { cleanupBackend } from './utils/cleanup.util';
|
||||||
|
|
||||||
test.beforeEach(cleanupBackend);
|
test.beforeEach(cleanupBackend);
|
||||||
@@ -142,7 +142,7 @@ test('Update user fails with already taken username', async ({ page }) => {
|
|||||||
test('Update user custom claims', async ({ page }) => {
|
test('Update user custom claims', async ({ page }) => {
|
||||||
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Expand card' }).click();
|
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||||
|
|
||||||
// Add two custom claims
|
// Add two custom claims
|
||||||
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
await page.getByRole('button', { name: 'Add custom claim' }).click();
|
||||||
@@ -178,3 +178,63 @@ test('Update user custom claims', async ({ page }) => {
|
|||||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
||||||
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
|
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Update user group assignments', async ({ page }) => {
|
||||||
|
const user = users.craig;
|
||||||
|
await page.goto(`/settings/admin/users/${user.id}`);
|
||||||
|
|
||||||
|
// Increase the test timeout since this test is complex
|
||||||
|
test.setTimeout(30000);
|
||||||
|
|
||||||
|
// Expand the user groups section if it's collapsed
|
||||||
|
const expandButton = page.getByRole('button', { name: 'Expand card' }).first();
|
||||||
|
if (await expandButton.isVisible()) {
|
||||||
|
await expandButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the user groups table to load
|
||||||
|
await page.waitForSelector('table');
|
||||||
|
|
||||||
|
// First, ensure we start with a clean state - uncheck any checked boxes
|
||||||
|
const developersCheckbox = page
|
||||||
|
.getByRole('row', { name: userGroups.developers.name })
|
||||||
|
.getByRole('checkbox');
|
||||||
|
const designersCheckbox = page
|
||||||
|
.getByRole('row', { name: userGroups.designers.name })
|
||||||
|
.getByRole('checkbox');
|
||||||
|
|
||||||
|
// Force click if needed to overcome element interception issues
|
||||||
|
if ((await developersCheckbox.getAttribute('data-state')) === 'checked') {
|
||||||
|
await developersCheckbox.click({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await designersCheckbox.getAttribute('data-state')) === 'checked') {
|
||||||
|
await designersCheckbox.click({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the changes to reset state if needed
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||||
|
|
||||||
|
// Wait for toast message to appear and disappear
|
||||||
|
await expect(page.getByRole('status')).toHaveText('User groups updated successfully');
|
||||||
|
await page.waitForTimeout(1000); // Wait for any animations or state changes
|
||||||
|
|
||||||
|
// Now add both groups (using force: true to avoid interception problems)
|
||||||
|
await developersCheckbox.click({ force: true });
|
||||||
|
await designersCheckbox.click({ force: true });
|
||||||
|
|
||||||
|
// Save the changes
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||||
|
|
||||||
|
// Verify success message
|
||||||
|
await expect(page.getByRole('status')).toHaveText('User groups updated successfully');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox')
|
||||||
|
).toHaveAttribute('data-state', 'checked', { timeout: 10000 });
|
||||||
|
await expect(
|
||||||
|
page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox')
|
||||||
|
).toHaveAttribute('data-state', 'checked', { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user