mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-22 17:25:06 +03:00
Compare commits
1 Commits
main
...
fix/callba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89dd07a7ba |
@@ -198,7 +198,6 @@ func initLogger(r *gin.Engine) {
|
|||||||
"GET /api/application-images/logo",
|
"GET /api/application-images/logo",
|
||||||
"GET /api/application-images/background",
|
"GET /api/application-images/background",
|
||||||
"GET /api/application-images/favicon",
|
"GET /api/application-images/favicon",
|
||||||
"GET /api/application-images/email",
|
|
||||||
"GET /_app",
|
"GET /_app",
|
||||||
"GET /fonts",
|
"GET /fonts",
|
||||||
"GET /healthz",
|
"GET /healthz",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a new access token that expires in 1 hour
|
// Create a new access token that expires in 1 hour
|
||||||
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour, false)
|
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour)
|
||||||
if txErr != nil {
|
if txErr != nil {
|
||||||
return fmt.Errorf("failed to generate access token: %w", txErr)
|
return fmt.Errorf("failed to generate access token: %w", txErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,13 +38,6 @@ type TokenInvalidOrExpiredError struct{}
|
|||||||
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||||
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
type DeviceCodeInvalid struct{}
|
|
||||||
|
|
||||||
func (e *DeviceCodeInvalid) Error() string {
|
|
||||||
return "one time access code must be used on the device it was generated for"
|
|
||||||
}
|
|
||||||
func (e *DeviceCodeInvalid) HttpStatusCode() int { return 400 }
|
|
||||||
|
|
||||||
type TokenInvalidError struct{}
|
type TokenInvalidError struct{}
|
||||||
|
|
||||||
func (e *TokenInvalidError) Error() string {
|
func (e *TokenInvalidError) Error() string {
|
||||||
|
|||||||
@@ -23,13 +23,11 @@ func NewAppImagesController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
group.GET("/application-images/logo", controller.getLogoHandler)
|
group.GET("/application-images/logo", controller.getLogoHandler)
|
||||||
group.GET("/application-images/email", controller.getEmailLogoHandler)
|
|
||||||
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
||||||
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
||||||
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
|
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
|
||||||
|
|
||||||
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
||||||
group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler)
|
|
||||||
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
||||||
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||||
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
||||||
@@ -61,18 +59,6 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
|
|||||||
c.getImage(ctx, imageName)
|
c.getImage(ctx, imageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEmailLogoHandler godoc
|
|
||||||
// @Summary Get email logo image
|
|
||||||
// @Description Get the email logo image for use in emails
|
|
||||||
// @Tags Application Images
|
|
||||||
// @Produce image/png
|
|
||||||
// @Produce image/jpeg
|
|
||||||
// @Success 200 {file} binary "Email logo image"
|
|
||||||
// @Router /api/application-images/email [get]
|
|
||||||
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
|
|
||||||
c.getImage(ctx, "logoEmail")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBackgroundImageHandler godoc
|
// getBackgroundImageHandler godoc
|
||||||
// @Summary Get background image
|
// @Summary Get background image
|
||||||
// @Description Get the background image for the application
|
// @Description Get the background image for the application
|
||||||
@@ -138,37 +124,6 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
|
|||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateEmailLogoHandler godoc
|
|
||||||
// @Summary Update email logo
|
|
||||||
// @Description Update the email logo for use in emails
|
|
||||||
// @Tags Application Images
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param file formData file true "Email logo image file"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Router /api/application-images/email [put]
|
|
||||||
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
|
|
||||||
file, err := ctx.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
_ = ctx.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
|
||||||
mimeType := utils.GetImageMimeType(fileType)
|
|
||||||
|
|
||||||
if mimeType != "image/png" && mimeType != "image/jpeg" {
|
|
||||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil {
|
|
||||||
_ = ctx.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateBackgroundImageHandler godoc
|
// updateBackgroundImageHandler godoc
|
||||||
// @Summary Update background image
|
// @Summary Update background image
|
||||||
// @Description Update the application background image
|
// @Description Update the application background image
|
||||||
|
|||||||
@@ -391,13 +391,12 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceToken, err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
|
err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie.AddDeviceTokenCookie(c, deviceToken)
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,8 +440,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
|||||||
// @Success 200 {object} dto.UserDto
|
// @Success 200 {object} dto.UserDto
|
||||||
// @Router /api/one-time-access-token/{token} [post]
|
// @Router /api/one-time-access-token/{token} [post]
|
||||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
|
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), c.ClientIP(), c.Request.UserAgent())
|
||||||
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -545,7 +543,7 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
|||||||
ttl = defaultSignupTokenDuration
|
ttl = defaultSignupTokenDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
|
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SignupTokenCreateDto struct {
|
type SignupTokenCreateDto struct {
|
||||||
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||||
UserGroupIDs []string `json:"userGroupIds"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignupTokenDto struct {
|
type SignupTokenDto struct {
|
||||||
@@ -17,6 +16,5 @@ type SignupTokenDto struct {
|
|||||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||||
UsageLimit int `json:"usageLimit"`
|
UsageLimit int `json:"usageLimit"`
|
||||||
UsageCount int `json:"usageCount"`
|
UsageCount int `json:"usageCount"`
|
||||||
UserGroups []UserGroupDto `json:"userGroups"`
|
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,16 +23,15 @@ type UserDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Locale *string `json:"locale"`
|
Locale *string `json:"locale"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
UserGroupIds []string `json:"userGroupIds"`
|
LdapID string `json:"-"`
|
||||||
LdapID string `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u UserCreateDto) Validate() error {
|
func (u UserCreateDto) Validate() error {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ type SignupToken struct {
|
|||||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||||
UsageCount int `json:"usageCount" sortable:"true"`
|
UsageCount int `json:"usageCount" sortable:"true"`
|
||||||
UserGroups []UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *SignupToken) IsExpired() bool {
|
func (st *SignupToken) IsExpired() bool {
|
||||||
|
|||||||
@@ -87,9 +87,8 @@ func (u User) Initials() string {
|
|||||||
|
|
||||||
type OneTimeAccessToken struct {
|
type OneTimeAccessToken struct {
|
||||||
Base
|
Base
|
||||||
Token string
|
Token string
|
||||||
DeviceToken *string
|
ExpiresAt datatype.DateTime
|
||||||
ExpiresAt datatype.DateTime
|
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
User User
|
User User
|
||||||
|
|||||||
@@ -344,9 +344,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||||
UsageLimit: 1,
|
UsageLimit: 1,
|
||||||
UsageCount: 0,
|
UsageCount: 0,
|
||||||
UserGroups: []model.UserGroup{
|
|
||||||
userGroups[0],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
|
|||||||
|
|
||||||
data := &email.TemplateData[V]{
|
data := &email.TemplateData[V]{
|
||||||
AppName: dbConfig.AppName.Value,
|
AppName: dbConfig.AppName.Value,
|
||||||
LogoURL: common.EnvConfig.AppURL + "/api/application-images/email",
|
LogoURL: common.EnvConfig.AppURL + "/api/application-images/logo",
|
||||||
Data: tData,
|
Data: tData,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -1196,7 +1195,7 @@ func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL
|
|||||||
|
|
||||||
// If URLs are already configured, validate against them
|
// If URLs are already configured, validate against them
|
||||||
if len(client.CallbackURLs) > 0 {
|
if len(client.CallbackURLs) > 0 {
|
||||||
matched, err := s.getCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
|
matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
} else if matched == "" {
|
} else if matched == "" {
|
||||||
@@ -1219,7 +1218,7 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout
|
|||||||
return client.LogoutCallbackURLs[0], nil
|
return client.LogoutCallbackURLs[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
matched, err := s.getCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
|
matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
} else if matched == "" {
|
} else if matched == "" {
|
||||||
@@ -1229,21 +1228,6 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout
|
|||||||
return matched, nil
|
return matched, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) getCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
|
||||||
for _, callbackPattern := range urls {
|
|
||||||
regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
|
|
||||||
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if matched {
|
|
||||||
return inputCallbackURL, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
|
func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
|
||||||
// Add the new callback URL to the existing list
|
// Add the new callback URL to the existing list
|
||||||
client.CallbackURLs = append(client.CallbackURLs, callbackURL)
|
client.CallbackURLs = append(client.CallbackURLs, callbackURL)
|
||||||
|
|||||||
@@ -253,18 +253,6 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
return model.User{}, &common.UserEmailNotSetError{}
|
return model.User{}, &common.UserEmailNotSetError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var userGroups []model.UserGroup
|
|
||||||
if len(input.UserGroupIds) > 0 {
|
|
||||||
err := tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
Where("id IN ?", input.UserGroupIds).
|
|
||||||
Find(&userGroups).
|
|
||||||
Error
|
|
||||||
if err != nil {
|
|
||||||
return model.User{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
user := model.User{
|
user := model.User{
|
||||||
FirstName: input.FirstName,
|
FirstName: input.FirstName,
|
||||||
LastName: input.LastName,
|
LastName: input.LastName,
|
||||||
@@ -274,7 +262,6 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
IsAdmin: input.IsAdmin,
|
IsAdmin: input.IsAdmin,
|
||||||
Locale: input.Locale,
|
Locale: input.Locale,
|
||||||
Disabled: input.Disabled,
|
Disabled: input.Disabled,
|
||||||
UserGroups: userGroups,
|
|
||||||
}
|
}
|
||||||
if input.LdapID != "" {
|
if input.LdapID != "" {
|
||||||
user.LdapID = &input.LdapID
|
user.LdapID = &input.LdapID
|
||||||
@@ -298,13 +285,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
|
|
||||||
// Apply default groups and claims for new non-LDAP users
|
// Apply default groups and claims for new non-LDAP users
|
||||||
if !isLdapSync {
|
if !isLdapSync {
|
||||||
if len(input.UserGroupIds) == 0 {
|
if err := s.applySignupDefaults(ctx, &user, tx); err != nil {
|
||||||
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
|
|
||||||
return model.User{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
|
|
||||||
return model.User{}, err
|
return model.User{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,9 +293,10 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||||
config := s.appConfigService.GetDbConfig()
|
config := s.appConfigService.GetDbConfig()
|
||||||
|
|
||||||
|
// Apply default user groups
|
||||||
var groupIDs []string
|
var groupIDs []string
|
||||||
v := config.SignupDefaultUserGroupIDs.Value
|
v := config.SignupDefaultUserGroupIDs.Value
|
||||||
if v != "" && v != "[]" {
|
if v != "" && v != "[]" {
|
||||||
@@ -341,14 +323,10 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
|
||||||
config := s.appConfigService.GetDbConfig()
|
|
||||||
|
|
||||||
|
// Apply default custom claims
|
||||||
var claims []dto.CustomClaimCreateDto
|
var claims []dto.CustomClaimCreateDto
|
||||||
v := config.SignupDefaultCustomClaims.Value
|
v = config.SignupDefaultCustomClaims.Value
|
||||||
if v != "" && v != "[]" {
|
if v != "" && v != "[]" {
|
||||||
err := json.Unmarshal([]byte(v), &claims)
|
err := json.Unmarshal([]byte(v), &claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -454,36 +432,28 @@ func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, user
|
|||||||
return &common.OneTimeAccessDisabledError{}
|
return &common.OneTimeAccessDisabledError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, true)
|
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
|
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
|
||||||
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
|
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
|
||||||
if isDisabled {
|
if isDisabled {
|
||||||
return "", &common.OneTimeAccessDisabledError{}
|
return &common.OneTimeAccessDisabledError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId string
|
var userId string
|
||||||
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
|
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
// Do not return error if user not found to prevent email enumeration
|
// Do not return error if user not found to prevent email enumeration
|
||||||
return "", nil
|
return nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
|
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
} else if deviceToken == nil {
|
|
||||||
return "", errors.New("device token expected but not returned")
|
|
||||||
}
|
|
||||||
|
|
||||||
return *deviceToken, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
|
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration) error {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -491,20 +461,21 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
|
|||||||
|
|
||||||
user, err := s.GetUser(ctx, userID)
|
user, err := s.GetUser(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Email == nil {
|
if user.Email == nil {
|
||||||
return nil, &common.UserEmailNotSetError{}
|
return &common.UserEmailNotSetError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
|
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// We use a background context here as this is running in a goroutine
|
// We use a background context here as this is running in a goroutine
|
||||||
@@ -537,29 +508,28 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return deviceToken, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
|
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (string, error) {
|
||||||
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
|
return s.createOneTimeAccessTokenInternal(ctx, userID, ttl, s.db)
|
||||||
return token, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
|
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, tx *gorm.DB) (string, error) {
|
||||||
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
|
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
|
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
|
return oneTimeAccessToken.Token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
|
func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token string, ipAddress, userAgent string) (model.User, string, error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -579,10 +549,6 @@ func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, dev
|
|||||||
}
|
}
|
||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
}
|
}
|
||||||
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
|
|
||||||
return model.User{}, "", &common.DeviceCodeInvalid{}
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
|
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
@@ -749,22 +715,12 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
|
|||||||
Error
|
Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) {
|
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int) (model.SignupToken, error) {
|
||||||
signupToken, err := NewSignupToken(ttl, usageLimit)
|
signupToken, err := NewSignupToken(ttl, usageLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.SignupToken{}, err
|
return model.SignupToken{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var userGroups []model.UserGroup
|
|
||||||
err = s.db.WithContext(ctx).
|
|
||||||
Where("id IN ?", userGroupIDs).
|
|
||||||
Find(&userGroups).
|
|
||||||
Error
|
|
||||||
if err != nil {
|
|
||||||
return model.SignupToken{}, err
|
|
||||||
}
|
|
||||||
signupToken.UserGroups = userGroups
|
|
||||||
|
|
||||||
err = s.db.WithContext(ctx).Create(signupToken).Error
|
err = s.db.WithContext(ctx).Create(signupToken).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.SignupToken{}, err
|
return model.SignupToken{}, err
|
||||||
@@ -787,11 +743,9 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
|||||||
}
|
}
|
||||||
|
|
||||||
var signupToken model.SignupToken
|
var signupToken model.SignupToken
|
||||||
var userGroupIDs []string
|
|
||||||
if tokenProvided {
|
if tokenProvided {
|
||||||
err := tx.
|
err := tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Preload("UserGroups").
|
|
||||||
Where("token = ?", signupData.Token).
|
Where("token = ?", signupData.Token).
|
||||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
First(&signupToken).
|
First(&signupToken).
|
||||||
@@ -806,19 +760,14 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
|||||||
if !signupToken.IsValid() {
|
if !signupToken.IsValid() {
|
||||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, group := range signupToken.UserGroups {
|
|
||||||
userGroupIDs = append(userGroupIDs, group.ID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userToCreate := dto.UserCreateDto{
|
userToCreate := dto.UserCreateDto{
|
||||||
Username: signupData.Username,
|
Username: signupData.Username,
|
||||||
Email: signupData.Email,
|
Email: signupData.Email,
|
||||||
FirstName: signupData.FirstName,
|
FirstName: signupData.FirstName,
|
||||||
LastName: signupData.LastName,
|
LastName: signupData.LastName,
|
||||||
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||||
UserGroupIds: userGroupIDs,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||||
@@ -859,7 +808,7 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
|||||||
|
|
||||||
func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
|
func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
|
||||||
var tokens []model.SignupToken
|
var tokens []model.SignupToken
|
||||||
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
|
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
|
||||||
|
|
||||||
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
|
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
|
||||||
return tokens, pagination, err
|
return tokens, pagination, err
|
||||||
@@ -869,33 +818,23 @@ func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) err
|
|||||||
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
|
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
|
func NewOneTimeAccessToken(userID string, ttl time.Duration) (*model.OneTimeAccessToken, error) {
|
||||||
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
||||||
tokenLength := 16
|
tokenLength := 16
|
||||||
if ttl <= 15*time.Minute {
|
if ttl <= 15*time.Minute {
|
||||||
tokenLength = 6
|
tokenLength = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := utils.GenerateRandomAlphanumericString(tokenLength)
|
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var deviceToken *string
|
|
||||||
if withDeviceToken {
|
|
||||||
dt, err := utils.GenerateRandomAlphanumericString(16)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
deviceToken = &dt
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Round(time.Second)
|
now := time.Now().Round(time.Second)
|
||||||
o := &model.OneTimeAccessToken{
|
o := &model.OneTimeAccessToken{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||||
Token: token,
|
Token: randomString,
|
||||||
DeviceToken: deviceToken,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return o, nil
|
return o, nil
|
||||||
|
|||||||
199
backend/internal/utils/callback_url_util.go
Normal file
199
backend/internal/utils/callback_url_util.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL
|
||||||
|
func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
||||||
|
// Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
|
||||||
|
//
|
||||||
|
// The authorization server MUST allow any port to be specified at the
|
||||||
|
// time of the request for loopback IP redirect URIs, to accommodate
|
||||||
|
// clients that obtain an available ephemeral port from the operating
|
||||||
|
// system at the time of the request.
|
||||||
|
loopbackRedirect := ""
|
||||||
|
u, _ := url.Parse(inputCallbackURL)
|
||||||
|
|
||||||
|
if u != nil && u.Scheme == "http" {
|
||||||
|
host := u.Hostname()
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if host == "localhost" || (ip != nil && ip.IsLoopback()) {
|
||||||
|
loopbackRedirect = u.String()
|
||||||
|
u.Host = host
|
||||||
|
inputCallbackURL = u.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range urls {
|
||||||
|
matches, err := matchCallbackURL(pattern, inputCallbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if !matches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if loopbackRedirect != "" {
|
||||||
|
return loopbackRedirect, nil
|
||||||
|
}
|
||||||
|
return inputCallbackURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchCallbackURL checks if the input callback URL matches the given pattern.
|
||||||
|
// It supports wildcard matching for paths and query parameters.
|
||||||
|
//
|
||||||
|
// The base URL (scheme, userinfo, host, port) and query parameters supports single '*' wildcards only,
|
||||||
|
// while the path supports both single '*' and double '**' wildcards.
|
||||||
|
func matchCallbackURL(pattern string, inputCallbackURL string) (matches bool, err error) {
|
||||||
|
if pattern == inputCallbackURL || pattern == "*" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip fragment part
|
||||||
|
// The endpoint URI MUST NOT include a fragment component.
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
|
||||||
|
pattern, _, _ = strings.Cut(pattern, "#")
|
||||||
|
inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#")
|
||||||
|
|
||||||
|
// Store and strip query part
|
||||||
|
var patternQuery url.Values
|
||||||
|
if i := strings.Index(pattern, "?"); i >= 0 {
|
||||||
|
patternQuery, err = url.ParseQuery(pattern[i+1:])
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
pattern = pattern[:i]
|
||||||
|
}
|
||||||
|
var inputQuery url.Values
|
||||||
|
if i := strings.Index(inputCallbackURL, "?"); i >= 0 {
|
||||||
|
inputQuery, err = url.ParseQuery(inputCallbackURL[i+1:])
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
inputCallbackURL = inputCallbackURL[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split both pattern and input parts
|
||||||
|
patternParts, patternPath := splitParts(pattern)
|
||||||
|
inputParts, inputPath := splitParts(inputCallbackURL)
|
||||||
|
|
||||||
|
// Verify everything except the path and query parameters
|
||||||
|
if len(patternParts) != len(inputParts) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, patternPart := range patternParts {
|
||||||
|
matched, err := path.Match(patternPart, inputParts[i])
|
||||||
|
if err != nil || !matched {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify path with wildcard support
|
||||||
|
matched, err := matchPath(patternPath, inputPath)
|
||||||
|
if err != nil || !matched {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify query parameters
|
||||||
|
if len(patternQuery) != len(inputQuery) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for patternKey, patternValues := range patternQuery {
|
||||||
|
inputValues, exists := inputQuery[patternKey]
|
||||||
|
if !exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(patternValues) != len(inputValues) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range patternValues {
|
||||||
|
matched, err := path.Match(patternValues[i], inputValues[i])
|
||||||
|
if err != nil || !matched {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPath matches the input path against the pattern with wildcard support
|
||||||
|
// Supported wildcards:
|
||||||
|
//
|
||||||
|
// '*' matches any sequence of characters except '/'
|
||||||
|
// '**' matches any sequence of characters including '/'
|
||||||
|
func matchPath(pattern string, input string) (matches bool, err error) {
|
||||||
|
var regexPattern strings.Builder
|
||||||
|
regexPattern.WriteString("^")
|
||||||
|
|
||||||
|
runes := []rune(pattern)
|
||||||
|
n := len(runes)
|
||||||
|
|
||||||
|
for i := 0; i < n; {
|
||||||
|
switch runes[i] {
|
||||||
|
case '*':
|
||||||
|
// Check if it's a ** (globstar)
|
||||||
|
if i+1 < n && runes[i+1] == '*' {
|
||||||
|
// globstar = .* (match slashes too)
|
||||||
|
regexPattern.WriteString(".*")
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
// single * = [^/]* (no slash)
|
||||||
|
regexPattern.WriteString(`[^/]*`)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
regexPattern.WriteString(regexp.QuoteMeta(string(runes[i])))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
regexPattern.WriteString("$")
|
||||||
|
|
||||||
|
matched, err := regexp.MatchString(regexPattern.String(), input)
|
||||||
|
return matched, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitParts splits the URL into parts by special characters and returns the path separately
|
||||||
|
func splitParts(s string) (parts []string, path string) {
|
||||||
|
split := func(r rune) bool {
|
||||||
|
return r == ':' || r == '/' || r == '[' || r == ']' || r == '@' || r == '.'
|
||||||
|
}
|
||||||
|
|
||||||
|
pathStart := -1
|
||||||
|
|
||||||
|
// Look for scheme:// first
|
||||||
|
if i := strings.Index(s, "://"); i >= 0 {
|
||||||
|
// Look for the next slash after scheme://
|
||||||
|
rest := s[i+3:]
|
||||||
|
if j := strings.IndexRune(rest, '/'); j >= 0 {
|
||||||
|
pathStart = i + 3 + j
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Otherwise, first slash is path start
|
||||||
|
pathStart = strings.IndexRune(s, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
if pathStart >= 0 {
|
||||||
|
path = s[pathStart:]
|
||||||
|
base := s[:pathStart]
|
||||||
|
parts = strings.FieldsFunc(base, split)
|
||||||
|
} else {
|
||||||
|
parts = strings.FieldsFunc(s, split)
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts, path
|
||||||
|
}
|
||||||
784
backend/internal/utils/callback_url_util_test.go
Normal file
784
backend/internal/utils/callback_url_util_test.go
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchCallbackURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pattern string
|
||||||
|
input string
|
||||||
|
shouldMatch bool
|
||||||
|
}{
|
||||||
|
// Basic matching
|
||||||
|
{
|
||||||
|
"exact match",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no match",
|
||||||
|
"https://example.org/callback",
|
||||||
|
"https://example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scheme
|
||||||
|
{
|
||||||
|
"scheme mismatch",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"http://example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard scheme",
|
||||||
|
"*://example.com/callback",
|
||||||
|
"https://example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hostname
|
||||||
|
{
|
||||||
|
"hostname mismatch",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://malicious.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard subdomain",
|
||||||
|
"https://*.example.com/callback",
|
||||||
|
"https://subdomain.example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in hostname prefix",
|
||||||
|
"https://app*.example.com/callback",
|
||||||
|
"https://app1.example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in hostname suffix",
|
||||||
|
"https://*-prod.example.com/callback",
|
||||||
|
"https://api-prod.example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in hostname middle",
|
||||||
|
"https://app-*-server.example.com/callback",
|
||||||
|
"https://app-staging-server.example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subdomain wildcard doesn't match domain hijack attempt",
|
||||||
|
"https://*.example.com/callback",
|
||||||
|
"https://malicious.site?url=abc.example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname mismatch with confusable characters",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://examp1e.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname mismatch with homograph attack",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://еxample.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Port
|
||||||
|
{
|
||||||
|
"port mismatch",
|
||||||
|
"https://example.com:8080/callback",
|
||||||
|
"https://example.com:9090/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard port",
|
||||||
|
"https://example.com:*/callback",
|
||||||
|
"https://example.com:8080/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in port prefix",
|
||||||
|
"https://example.com:80*/callback",
|
||||||
|
"https://example.com:8080/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Path
|
||||||
|
{
|
||||||
|
"path mismatch",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/other",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard path segment",
|
||||||
|
"https://example.com/api/*/callback",
|
||||||
|
"https://example.com/api/v1/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard entire path",
|
||||||
|
"https://example.com/*",
|
||||||
|
"https://example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in path prefix",
|
||||||
|
"https://example.com/test*",
|
||||||
|
"https://example.com/testcase",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in path suffix",
|
||||||
|
"https://example.com/*-callback",
|
||||||
|
"https://example.com/oauth-callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in path middle",
|
||||||
|
"https://example.com/api-*-v1/callback",
|
||||||
|
"https://example.com/api-internal-v1/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple partial wildcards in path",
|
||||||
|
"https://example.com/*/test*/callback",
|
||||||
|
"https://example.com/v1/testing/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple wildcard segments in path",
|
||||||
|
"https://example.com/**/callback",
|
||||||
|
"https://example.com/api/v1/foo/bar/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple wildcard segments in path",
|
||||||
|
"https://example.com/**/v1/**/callback",
|
||||||
|
"https://example.com/api/v1/foo/bar/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard matching full path segment",
|
||||||
|
"https://example.com/foo-*",
|
||||||
|
"https://example.com/foo-bar",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Credentials
|
||||||
|
{
|
||||||
|
"username mismatch",
|
||||||
|
"https://user:pass@example.com/callback",
|
||||||
|
"https://admin:pass@example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"missing credentials",
|
||||||
|
"https://user:pass@example.com/callback",
|
||||||
|
"https://example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unexpected credentials",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://user:pass@example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard password",
|
||||||
|
"https://user:*@example.com/callback",
|
||||||
|
"https://user:secret123@example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in username",
|
||||||
|
"https://admin*:pass@example.com/callback",
|
||||||
|
"https://admin123:pass@example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in password",
|
||||||
|
"https://user:pass*@example.com/callback",
|
||||||
|
"https://user:password123@example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard password doesn't allow domain hijack",
|
||||||
|
"https://user:*@example.com/callback",
|
||||||
|
"https://user:password@malicious.site#example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"credentials with @ in password trying to hijack hostname",
|
||||||
|
"https://user:pass@example.com/callback",
|
||||||
|
"https://user:pass@evil.com@example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Query parameters
|
||||||
|
{
|
||||||
|
"extra query parameter",
|
||||||
|
"https://example.com/callback?code=*",
|
||||||
|
"https://example.com/callback?code=abc123&extra=value",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"missing query parameter",
|
||||||
|
"https://example.com/callback?code=*&state=*",
|
||||||
|
"https://example.com/callback?code=abc123",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameter after fragment",
|
||||||
|
"https://example.com/callback?code=123",
|
||||||
|
"https://example.com/callback#section?code=123",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameter name mismatch",
|
||||||
|
"https://example.com/callback?code=*",
|
||||||
|
"https://example.com/callback?token=abc123",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard query parameter",
|
||||||
|
"https://example.com/callback?code=*",
|
||||||
|
"https://example.com/callback?code=abc123",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple query parameters",
|
||||||
|
"https://example.com/callback?code=*&state=*",
|
||||||
|
"https://example.com/callback?code=abc123&state=xyz789",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameters in different order",
|
||||||
|
"https://example.com/callback?state=*&code=*",
|
||||||
|
"https://example.com/callback?code=abc123&state=xyz789",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exact query parameter value",
|
||||||
|
"https://example.com/callback?mode=production",
|
||||||
|
"https://example.com/callback?mode=production",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameter value mismatch",
|
||||||
|
"https://example.com/callback?mode=production",
|
||||||
|
"https://example.com/callback?mode=development",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mixed exact and wildcard query parameters",
|
||||||
|
"https://example.com/callback?mode=production&code=*",
|
||||||
|
"https://example.com/callback?mode=production&code=abc123",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mixed exact and wildcard with wrong exact value",
|
||||||
|
"https://example.com/callback?mode=production&code=*",
|
||||||
|
"https://example.com/callback?mode=development&code=abc123",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple values for same parameter",
|
||||||
|
"https://example.com/callback?param=*¶m=*",
|
||||||
|
"https://example.com/callback?param=value1¶m=value2",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unexpected query parameters",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback?extra=value",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameter with redirect to external site",
|
||||||
|
"https://example.com/callback?code=*",
|
||||||
|
"https://example.com/callback?code=123&redirect=https://evil.com",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"open redirect via encoded URL in query param",
|
||||||
|
"https://example.com/callback?state=*",
|
||||||
|
"https://example.com/callback?state=abc&next=//evil.com",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fragment
|
||||||
|
{
|
||||||
|
"fragment ignored when both pattern and input have fragment",
|
||||||
|
"https://example.com/callback#fragment",
|
||||||
|
"https://example.com/callback#fragment",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fragment ignored when pattern has fragment but input doesn't",
|
||||||
|
"https://example.com/callback#fragment",
|
||||||
|
"https://example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fragment ignored when input has fragment but pattern doesn't",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback#section",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Path traversal and injection attempts
|
||||||
|
{
|
||||||
|
"path traversal attempt",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/../admin/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"backslash instead of forward slash",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com\\callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"double slash in hostname (protocol smuggling)",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com//evil.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"CRLF injection attempt in path",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback%0d%0aLocation:%20https://evil.com",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"null byte injection",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback%00.evil.com",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
matches, err := matchCallbackURL(tt.pattern, tt.input)
|
||||||
|
require.NoError(t, err, tt.name)
|
||||||
|
assert.Equal(t, tt.shouldMatch, matches, tt.name)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
urls []string
|
||||||
|
inputCallbackURL string
|
||||||
|
expectedURL string
|
||||||
|
expectMatch bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "127.0.0.1 with dynamic port - exact match",
|
||||||
|
urls: []string{"http://127.0.0.1/callback"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:8080/callback",
|
||||||
|
expectedURL: "http://127.0.0.1:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "127.0.0.1 with different port",
|
||||||
|
urls: []string{"http://127.0.0.1/callback"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:9999/callback",
|
||||||
|
expectedURL: "http://127.0.0.1:9999/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback with dynamic port",
|
||||||
|
urls: []string{"http://[::1]/callback"},
|
||||||
|
inputCallbackURL: "http://[::1]:8080/callback",
|
||||||
|
expectedURL: "http://[::1]:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback without brackets in input",
|
||||||
|
urls: []string{"http://[::1]/callback"},
|
||||||
|
inputCallbackURL: "http://::1:8080/callback",
|
||||||
|
expectedURL: "http://::1:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "localhost with dynamic port",
|
||||||
|
urls: []string{"http://localhost/callback"},
|
||||||
|
inputCallbackURL: "http://localhost:8080/callback",
|
||||||
|
expectedURL: "http://localhost:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https loopback doesn't trigger special handling",
|
||||||
|
urls: []string{"https://127.0.0.1/callback"},
|
||||||
|
inputCallbackURL: "https://127.0.0.1:8080/callback",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "loopback with path match",
|
||||||
|
urls: []string{"http://127.0.0.1/auth/*"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:3000/auth/callback",
|
||||||
|
expectedURL: "http://127.0.0.1:3000/auth/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "loopback with path mismatch",
|
||||||
|
urls: []string{"http://127.0.0.1/callback"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:8080/different",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-loopback IP",
|
||||||
|
urls: []string{"http://192.168.1.1/callback"},
|
||||||
|
inputCallbackURL: "http://192.168.1.1:8080/callback",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard matches loopback",
|
||||||
|
urls: []string{"*"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:8080/callback",
|
||||||
|
expectedURL: "http://127.0.0.1:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := GetCallbackURLFromList(tt.urls, tt.inputCallbackURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.expectMatch {
|
||||||
|
assert.Equal(t, tt.expectedURL, result)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
urls []string
|
||||||
|
inputCallbackURL string
|
||||||
|
expectedURL string
|
||||||
|
expectMatch bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "matches first pattern",
|
||||||
|
urls: []string{
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.org/callback",
|
||||||
|
},
|
||||||
|
inputCallbackURL: "https://example.com/callback",
|
||||||
|
expectedURL: "https://example.com/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matches second pattern",
|
||||||
|
urls: []string{
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.org/callback",
|
||||||
|
},
|
||||||
|
inputCallbackURL: "https://example.org/callback",
|
||||||
|
expectedURL: "https://example.org/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matches none",
|
||||||
|
urls: []string{
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.org/callback",
|
||||||
|
},
|
||||||
|
inputCallbackURL: "https://malicious.com/callback",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matches wildcard pattern",
|
||||||
|
urls: []string{
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://*.example.org/callback",
|
||||||
|
},
|
||||||
|
inputCallbackURL: "https://subdomain.example.org/callback",
|
||||||
|
expectedURL: "https://subdomain.example.org/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty pattern list",
|
||||||
|
urls: []string{},
|
||||||
|
inputCallbackURL: "https://example.com/callback",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := GetCallbackURLFromList(tt.urls, tt.inputCallbackURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.expectMatch {
|
||||||
|
assert.Equal(t, tt.expectedURL, result)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pattern string
|
||||||
|
input string
|
||||||
|
shouldMatch bool
|
||||||
|
}{
|
||||||
|
// Exact matches
|
||||||
|
{
|
||||||
|
name: "exact match",
|
||||||
|
pattern: "/callback",
|
||||||
|
input: "/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact mismatch",
|
||||||
|
pattern: "/callback",
|
||||||
|
input: "/other",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty paths",
|
||||||
|
pattern: "",
|
||||||
|
input: "",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Single wildcard (*)
|
||||||
|
{
|
||||||
|
name: "single wildcard matches segment",
|
||||||
|
pattern: "/api/*/callback",
|
||||||
|
input: "/api/v1/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single wildcard doesn't match multiple segments",
|
||||||
|
pattern: "/api/*/callback",
|
||||||
|
input: "/api/v1/v2/callback",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single wildcard at end",
|
||||||
|
pattern: "/callback/*",
|
||||||
|
input: "/callback/test",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single wildcard at start",
|
||||||
|
pattern: "/*/callback",
|
||||||
|
input: "/api/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple single wildcards",
|
||||||
|
pattern: "/*/test/*",
|
||||||
|
input: "/api/test/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial wildcard prefix",
|
||||||
|
pattern: "/test*",
|
||||||
|
input: "/testing",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial wildcard suffix",
|
||||||
|
pattern: "/*-callback",
|
||||||
|
input: "/oauth-callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial wildcard middle",
|
||||||
|
pattern: "/api-*-v1",
|
||||||
|
input: "/api-internal-v1",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Double wildcard (**)
|
||||||
|
{
|
||||||
|
name: "double wildcard matches multiple segments",
|
||||||
|
pattern: "/api/**/callback",
|
||||||
|
input: "/api/v1/v2/v3/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double wildcard matches single segment",
|
||||||
|
pattern: "/api/**/callback",
|
||||||
|
input: "/api/v1/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double wildcard doesn't match when pattern has extra slashes",
|
||||||
|
pattern: "/api/**/callback",
|
||||||
|
input: "/api/callback",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double wildcard at end",
|
||||||
|
pattern: "/api/**",
|
||||||
|
input: "/api/v1/v2/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double wildcard in middle",
|
||||||
|
pattern: "/api/**/v2/**/callback",
|
||||||
|
input: "/api/v1/v2/v3/v4/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Complex patterns
|
||||||
|
{
|
||||||
|
name: "mix of single and double wildcards",
|
||||||
|
pattern: "/*/api/**/callback",
|
||||||
|
input: "/app/api/v1/v2/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard with special characters",
|
||||||
|
pattern: "/callback-*",
|
||||||
|
input: "/callback-123",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with query-like string (no special handling)",
|
||||||
|
pattern: "/callback?code=*",
|
||||||
|
input: "/callback?code=abc",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
{
|
||||||
|
name: "single wildcard matches empty segment",
|
||||||
|
pattern: "/api/*/callback",
|
||||||
|
input: "/api//callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pattern longer than input",
|
||||||
|
pattern: "/api/v1/callback",
|
||||||
|
input: "/api",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "input longer than pattern",
|
||||||
|
pattern: "/api",
|
||||||
|
input: "/api/v1/callback",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
matches, err := matchPath(tt.pattern, tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.shouldMatch, matches)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitParts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expectedParts []string
|
||||||
|
expectedPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple https URL",
|
||||||
|
input: "https://example.com/callback",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with port",
|
||||||
|
input: "https://example.com:8080/callback",
|
||||||
|
expectedParts: []string{"https", "example", "com", "8080"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with subdomain",
|
||||||
|
input: "https://api.example.com/callback",
|
||||||
|
expectedParts: []string{"https", "api", "example", "com"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with credentials",
|
||||||
|
input: "https://user:pass@example.com/callback",
|
||||||
|
expectedParts: []string{"https", "user", "pass", "example", "com"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL without path",
|
||||||
|
input: "https://example.com",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with deep path",
|
||||||
|
input: "https://example.com/api/v1/callback",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "/api/v1/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with path and query",
|
||||||
|
input: "https://example.com/callback?code=123",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "/callback?code=123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with trailing slash",
|
||||||
|
input: "https://example.com/",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with multiple subdomains",
|
||||||
|
input: "https://api.v1.staging.example.com/callback",
|
||||||
|
expectedParts: []string{"https", "api", "v1", "staging", "example", "com"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with port and credentials",
|
||||||
|
input: "https://user:pass@example.com:8080/callback",
|
||||||
|
expectedParts: []string{"https", "user", "pass", "example", "com", "8080"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scheme with authority separator but no slash",
|
||||||
|
input: "http://example.com",
|
||||||
|
expectedParts: []string{"http", "example", "com"},
|
||||||
|
expectedPath: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
parts, path := splitParts(tt.input)
|
||||||
|
assert.Equal(t, tt.expectedParts, parts, "parts mismatch")
|
||||||
|
assert.Equal(t, tt.expectedPath, path, "path mismatch")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package cookie
|
package cookie
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,7 +11,3 @@ func AddAccessTokenCookie(c *gin.Context, maxAgeInSeconds int, token string) {
|
|||||||
func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
|
func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
|
||||||
c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true)
|
c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddDeviceTokenCookie(c *gin.Context, deviceToken string) {
|
|
||||||
c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ import (
|
|||||||
|
|
||||||
var AccessTokenCookieName = "__Host-access_token"
|
var AccessTokenCookieName = "__Host-access_token"
|
||||||
var SessionIdCookieName = "__Host-session"
|
var SessionIdCookieName = "__Host-session"
|
||||||
var DeviceTokenCookieName = "__Host-device_token" //nolint:gosec
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
|
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
|
||||||
AccessTokenCookieName = "access_token"
|
AccessTokenCookieName = "access_token"
|
||||||
SessionIdCookieName = "session"
|
SessionIdCookieName = "session"
|
||||||
DeviceTokenCookieName = "device_token"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 566 B |
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE one_time_access_tokens DROP COLUMN device_token;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE one_time_access_tokens ADD COLUMN device_token VARCHAR(16);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE signup_tokens_user_groups;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
CREATE TABLE signup_tokens_user_groups
|
|
||||||
(
|
|
||||||
signup_token_id UUID NOT NULL,
|
|
||||||
user_group_id UUID NOT NULL,
|
|
||||||
PRIMARY KEY (signup_token_id, user_group_id),
|
|
||||||
FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
ALTER TABLE one_time_access_tokens DROP COLUMN device_token;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
DROP TABLE signup_tokens_user_groups;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE TABLE signup_tokens_user_groups
|
|
||||||
(
|
|
||||||
signup_token_id TEXT NOT NULL,
|
|
||||||
user_group_id TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (signup_token_id, user_group_id),
|
|
||||||
FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
@@ -311,7 +311,6 @@
|
|||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"light_mode_logo": "Light Mode Logo",
|
"light_mode_logo": "Light Mode Logo",
|
||||||
"dark_mode_logo": "Dark Mode Logo",
|
"dark_mode_logo": "Dark Mode Logo",
|
||||||
"email_logo": "Email Logo",
|
|
||||||
"background_image": "Background Image",
|
"background_image": "Background Image",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"reset_profile_picture_question": "Reset profile picture?",
|
"reset_profile_picture_question": "Reset profile picture?",
|
||||||
@@ -350,8 +349,8 @@
|
|||||||
"login_code_email_success": "The login code has been sent to the user.",
|
"login_code_email_success": "The login code has been sent to the user.",
|
||||||
"send_email": "Send Email",
|
"send_email": "Send Email",
|
||||||
"show_code": "Show Code",
|
"show_code": "Show Code",
|
||||||
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
|
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> are supported.",
|
||||||
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
|
"logout_callback_url_description": "URL(s) provided by your client for logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> are supported.",
|
||||||
"api_key_expiration": "API Key Expiration",
|
"api_key_expiration": "API Key Expiration",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
||||||
"authorize_device": "Authorize Device",
|
"authorize_device": "Authorize Device",
|
||||||
@@ -470,6 +469,5 @@
|
|||||||
"default_profile_picture": "Default Profile Picture",
|
"default_profile_picture": "Default Profile Picture",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"system": "System",
|
"system": "System"
|
||||||
"signup_token_user_groups_description": "Automatically assign these groups to users who sign up using this token."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,7 @@
|
|||||||
import { LucideExternalLink } from '@lucide/svelte';
|
import { LucideExternalLink } from '@lucide/svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
import FormattedMessage from '../formatted-message.svelte';
|
||||||
type WithoutChildren = {
|
|
||||||
children?: undefined;
|
|
||||||
input?: FormInput<string | boolean | number | Date | undefined>;
|
|
||||||
labelFor?: never;
|
|
||||||
};
|
|
||||||
type WithChildren = {
|
|
||||||
children: Snippet;
|
|
||||||
input?: any;
|
|
||||||
labelFor?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
input = $bindable(),
|
input = $bindable(),
|
||||||
@@ -29,29 +19,29 @@
|
|||||||
type = 'text',
|
type = 'text',
|
||||||
children,
|
children,
|
||||||
onInput,
|
onInput,
|
||||||
labelFor,
|
|
||||||
...restProps
|
...restProps
|
||||||
}: HTMLAttributes<HTMLDivElement> &
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
(WithChildren | WithoutChildren) & {
|
input?: FormInput<string | boolean | number | Date | undefined>;
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
docsLink?: string;
|
docsLink?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
|
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
|
||||||
onInput?: (e: FormInputEvent) => void;
|
onInput?: (e: FormInputEvent) => void;
|
||||||
} = $props();
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const id = label?.toLowerCase().replace(/ /g, '-');
|
const id = label?.toLowerCase().replace(/ /g, '-');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps}>
|
<div {...restProps}>
|
||||||
{#if label}
|
{#if label}
|
||||||
<Label required={input?.required} class="mb-0" for={labelFor ?? id}>{label}</Label>
|
<Label required={input?.required} class="mb-0" for={id}>{label}</Label>
|
||||||
{/if}
|
{/if}
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-muted-foreground mt-1 text-xs">
|
<p class="text-muted-foreground mt-1 text-xs">
|
||||||
{description}
|
<FormattedMessage m={description} />
|
||||||
{#if docsLink}
|
{#if docsLink}
|
||||||
<a
|
<a
|
||||||
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:translate-y-[-1px] after:bg-white dark:text-white"
|
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:translate-y-[-1px] after:bg-white dark:text-white"
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import SearchableMultiSelect from '$lib/components/form/searchable-multi-select.svelte';
|
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
|
||||||
import { debounced } from '$lib/utils/debounce-util';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
let {
|
|
||||||
selectedGroupIds = $bindable()
|
|
||||||
}: {
|
|
||||||
selectedGroupIds: string[];
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
|
||||||
|
|
||||||
let userGroups = $state<{ value: string; label: string }[]>([]);
|
|
||||||
let isLoading = $state(false);
|
|
||||||
|
|
||||||
async function loadUserGroups(search?: string) {
|
|
||||||
userGroups = (await userGroupService.list({ search })).data.map((group) => ({
|
|
||||||
value: group.id,
|
|
||||||
label: group.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Ensure selected groups are still in the list
|
|
||||||
for (const selectedGroupId of selectedGroupIds) {
|
|
||||||
if (!userGroups.some((g) => g.value === selectedGroupId)) {
|
|
||||||
const group = await userGroupService.get(selectedGroupId);
|
|
||||||
userGroups.push({ value: group.id, label: group.name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onUserGroupSearch = debounced(
|
|
||||||
async (search: string) => await loadUserGroups(search),
|
|
||||||
300,
|
|
||||||
(loading) => (isLoading = loading)
|
|
||||||
);
|
|
||||||
|
|
||||||
onMount(() => loadUserGroups());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<SearchableMultiSelect
|
|
||||||
id="default-groups"
|
|
||||||
items={userGroups}
|
|
||||||
oninput={(e) => onUserGroupSearch(e.currentTarget.value)}
|
|
||||||
selectedItems={selectedGroupIds}
|
|
||||||
onSelect={(selected) => (selectedGroupIds = selected)}
|
|
||||||
{isLoading}
|
|
||||||
disableInternalSearch
|
|
||||||
/>
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
AdvancedTableColumn,
|
AdvancedTableColumn,
|
||||||
CreateAdvancedTableActions
|
CreateAdvancedTableActions
|
||||||
} from '$lib/types/advanced-table.type';
|
} from '$lib/types/advanced-table.type';
|
||||||
import type { SignupToken } from '$lib/types/signup-token.type';
|
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { Copy, Trash2 } from '@lucide/svelte';
|
import { Copy, Trash2 } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -23,14 +23,14 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
let tableRef: AdvancedTable<SignupToken>;
|
let tableRef: AdvancedTable<SignupTokenDto>;
|
||||||
|
|
||||||
function formatDate(dateStr: string | undefined) {
|
function formatDate(dateStr: string | undefined) {
|
||||||
if (!dateStr) return m.never();
|
if (!dateStr) return m.never();
|
||||||
return new Date(dateStr).toLocaleString();
|
return new Date(dateStr).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteToken(token: SignupToken) {
|
async function deleteToken(token: SignupTokenDto) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
title: m.delete_signup_token(),
|
title: m.delete_signup_token(),
|
||||||
message: m.are_you_sure_you_want_to_delete_this_signup_token(),
|
message: m.are_you_sure_you_want_to_delete_this_signup_token(),
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
return new Date(expiresAt) < new Date();
|
return new Date(expiresAt) < new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTokenUsedUp(token: SignupToken) {
|
function isTokenUsedUp(token: SignupTokenDto) {
|
||||||
return token.usageCount >= token.usageLimit;
|
return token.usageCount >= token.usageLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenStatus(token: SignupToken) {
|
function getTokenStatus(token: SignupTokenDto) {
|
||||||
if (isTokenExpired(token.expiresAt)) return 'expired';
|
if (isTokenExpired(token.expiresAt)) return 'expired';
|
||||||
if (isTokenUsedUp(token)) return 'used-up';
|
if (isTokenUsedUp(token)) return 'used-up';
|
||||||
return 'active';
|
return 'active';
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copySignupLink(token: SignupToken) {
|
function copySignupLink(token: SignupTokenDto) {
|
||||||
const signupLink = `${page.url.origin}/st/${token.token}`;
|
const signupLink = `${page.url.origin}/st/${token.token}`;
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(signupLink)
|
.writeText(signupLink)
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: AdvancedTableColumn<SignupToken>[] = [
|
const columns: AdvancedTableColumn<SignupTokenDto>[] = [
|
||||||
{ label: m.token(), column: 'token', cell: TokenCell },
|
{ label: m.token(), column: 'token', cell: TokenCell },
|
||||||
{ label: m.status(), key: 'status', cell: StatusCell },
|
{ label: m.status(), key: 'status', cell: StatusCell },
|
||||||
{
|
{
|
||||||
@@ -106,12 +106,7 @@
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
value: (item) => formatDate(item.expiresAt)
|
value: (item) => formatDate(item.expiresAt)
|
||||||
},
|
},
|
||||||
{
|
{ label: 'Usage Limit', column: 'usageLimit' },
|
||||||
key: 'userGroups',
|
|
||||||
label: m.user_groups(),
|
|
||||||
value: (item) => item.userGroups.map((g) => g.name).join(', '),
|
|
||||||
hidden: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: m.created(),
|
label: m.created(),
|
||||||
column: 'createdAt',
|
column: 'createdAt',
|
||||||
@@ -121,7 +116,7 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const actions: CreateAdvancedTableActions<SignupToken> = (_) => [
|
const actions: CreateAdvancedTableActions<SignupTokenDto> = (_) => [
|
||||||
{
|
{
|
||||||
label: m.copy(),
|
label: m.copy(),
|
||||||
icon: Copy,
|
icon: Copy,
|
||||||
@@ -136,13 +131,13 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet TokenCell({ item }: { item: SignupToken })}
|
{#snippet TokenCell({ item }: { item: SignupTokenDto })}
|
||||||
<span class="font-mono text-xs">
|
<span class="font-mono text-xs">
|
||||||
{item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
|
{item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet StatusCell({ item }: { item: SignupToken })}
|
{#snippet StatusCell({ item }: { item: SignupTokenDto })}
|
||||||
{@const status = getTokenStatus(item)}
|
{@const status = getTokenStatus(item)}
|
||||||
{@const statusBadge = getStatusBadge(status)}
|
{@const statusBadge = getStatusBadge(status)}
|
||||||
<Badge class="rounded-full" variant={statusBadge.variant}>
|
<Badge class="rounded-full" variant={statusBadge.variant}>
|
||||||
@@ -150,7 +145,7 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet UsageCell({ item }: { item: SignupToken })}
|
{#snippet UsageCell({ item }: { item: SignupTokenDto })}
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
{item.usageCount}
|
{item.usageCount}
|
||||||
{m.of()}
|
{m.of()}
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||||
import FormInput from '$lib/components/form/form-input.svelte';
|
|
||||||
import UserGroupInput from '$lib/components/form/user-group-input.svelte';
|
|
||||||
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
|
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
|
||||||
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';
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
import * as Select from '$lib/components/ui/select/index.js';
|
import * as Select from '$lib/components/ui/select/index.js';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import AppConfigService from '$lib/services/app-config-service';
|
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
|
||||||
import { createForm } from '$lib/utils/form-util';
|
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { z } from 'zod/v4';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable()
|
open = $bindable()
|
||||||
@@ -25,74 +19,29 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
const appConfigService = new AppConfigService();
|
|
||||||
|
|
||||||
const DEFAULT_TTL_SECONDS = 60 * 60 * 24;
|
|
||||||
const availableExpirations = [
|
|
||||||
{ label: m.one_hour(), value: 60 * 60 },
|
|
||||||
{ label: m.twelve_hours(), value: 60 * 60 * 12 },
|
|
||||||
{ label: m.one_day(), value: DEFAULT_TTL_SECONDS },
|
|
||||||
{ label: m.one_week(), value: DEFAULT_TTL_SECONDS * 7 },
|
|
||||||
{ label: m.one_month(), value: DEFAULT_TTL_SECONDS * 30 }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const defaultExpiration =
|
|
||||||
availableExpirations.find((exp) => exp.value === DEFAULT_TTL_SECONDS)?.value ??
|
|
||||||
availableExpirations[0].value;
|
|
||||||
|
|
||||||
type SignupTokenForm = {
|
|
||||||
ttl: number;
|
|
||||||
usageLimit: number;
|
|
||||||
userGroupIds: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialFormValues: SignupTokenForm = {
|
|
||||||
ttl: defaultExpiration,
|
|
||||||
usageLimit: 1,
|
|
||||||
userGroupIds: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
ttl: z.number(),
|
|
||||||
usageLimit: z.number().min(1).max(100),
|
|
||||||
userGroupIds: z.array(z.string()).default([])
|
|
||||||
});
|
|
||||||
|
|
||||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, initialFormValues);
|
|
||||||
|
|
||||||
let signupToken: string | null = $state(null);
|
let signupToken: string | null = $state(null);
|
||||||
let signupLink: string | null = $state(null);
|
let signupLink: string | null = $state(null);
|
||||||
let createdSignupData: SignupTokenForm | null = $state(null);
|
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_day());
|
||||||
let isLoading = $state(false);
|
let usageLimit: number = $state(1);
|
||||||
|
|
||||||
let defaultUserGroupIds: string[] = [];
|
let availableExpirations = {
|
||||||
|
[m.one_hour()]: 60 * 60,
|
||||||
function getExpirationLabel(ttl: number) {
|
[m.twelve_hours()]: 60 * 60 * 12,
|
||||||
return availableExpirations.find((exp) => exp.value === ttl)?.label ?? '';
|
[m.one_day()]: 60 * 60 * 24,
|
||||||
}
|
[m.one_week()]: 60 * 60 * 24 * 7,
|
||||||
|
[m.one_month()]: 60 * 60 * 24 * 30
|
||||||
function resetForm() {
|
};
|
||||||
form.reset();
|
|
||||||
form.setValue('userGroupIds', defaultUserGroupIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSignupToken() {
|
async function createSignupToken() {
|
||||||
const data = form.validate();
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
isLoading = true;
|
|
||||||
try {
|
try {
|
||||||
signupToken = await userService.createSignupToken(
|
signupToken = await userService.createSignupToken(
|
||||||
data.ttl,
|
availableExpirations[selectedExpiration],
|
||||||
data.usageLimit,
|
usageLimit
|
||||||
data.userGroupIds
|
|
||||||
);
|
);
|
||||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||||
createdSignupData = data;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,22 +50,10 @@
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
signupToken = null;
|
signupToken = null;
|
||||||
signupLink = null;
|
signupLink = null;
|
||||||
createdSignupData = null;
|
selectedExpiration = m.one_day();
|
||||||
resetForm();
|
usageLimit = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
appConfigService
|
|
||||||
.list(true)
|
|
||||||
.then((response) => {
|
|
||||||
const responseGroupIds = response.signupDefaultUserGroupIDs || [];
|
|
||||||
defaultUserGroupIds = responseGroupIds;
|
|
||||||
initialFormValues.userGroupIds = responseGroupIds;
|
|
||||||
form.setValue('userGroupIds', responseGroupIds);
|
|
||||||
})
|
|
||||||
.catch(axiosErrorToast);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root {open} {onOpenChange}>
|
<Dialog.Root {open} {onOpenChange}>
|
||||||
@@ -129,57 +66,49 @@
|
|||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
{#if signupToken === null}
|
{#if signupToken === null}
|
||||||
<form class="space-y-4" onsubmit={preventDefault(createSignupToken)}>
|
<div class="space-y-4">
|
||||||
<FormInput labelFor="expiration" label={m.expiration()} input={$inputs.ttl}>
|
<div>
|
||||||
|
<Label for="expiration">{m.expiration()}</Label>
|
||||||
<Select.Root
|
<Select.Root
|
||||||
type="single"
|
type="single"
|
||||||
value={$inputs.ttl.value.toString()}
|
value={Object.keys(availableExpirations)[0]}
|
||||||
onValueChange={(v) => v && form.setValue('ttl', Number(v))}
|
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
|
||||||
>
|
>
|
||||||
<Select.Trigger id="expiration" class="h-9 w-full">
|
<Select.Trigger id="expiration" class="h-9 w-full">
|
||||||
{getExpirationLabel($inputs.ttl.value)}
|
{selectedExpiration}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each availableExpirations as expiration}
|
{#each Object.keys(availableExpirations) as key}
|
||||||
<Select.Item value={expiration.value.toString()}>
|
<Select.Item value={key}>{key}</Select.Item>
|
||||||
{expiration.label}
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
{#if $inputs.ttl.error}
|
</div>
|
||||||
<p class="text-destructive mt-1 text-xs">{$inputs.ttl.error}</p>
|
|
||||||
{/if}
|
<div>
|
||||||
</FormInput>
|
<Label class="mb-0" for="usage-limit">{m.usage_limit()}</Label>
|
||||||
<FormInput
|
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||||
labelFor="usage-limit"
|
{m.number_of_times_token_can_be_used()}
|
||||||
label={m.usage_limit()}
|
</p>
|
||||||
description={m.number_of_times_token_can_be_used()}
|
|
||||||
input={$inputs.usageLimit}
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
id="usage-limit"
|
id="usage-limit"
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={$inputs.usageLimit.value}
|
min="1"
|
||||||
aria-invalid={$inputs.usageLimit.error ? 'true' : undefined}
|
max="100"
|
||||||
|
bind:value={usageLimit}
|
||||||
class="h-9"
|
class="h-9"
|
||||||
/>
|
/>
|
||||||
</FormInput>
|
</div>
|
||||||
<FormInput
|
</div>
|
||||||
labelFor="default-groups"
|
|
||||||
label={m.user_groups()}
|
|
||||||
description={m.signup_token_user_groups_description()}
|
|
||||||
input={$inputs.userGroupIds}
|
|
||||||
>
|
|
||||||
<UserGroupInput bind:selectedGroupIds={$inputs.userGroupIds.value} />
|
|
||||||
</FormInput>
|
|
||||||
|
|
||||||
<Dialog.Footer class="mt-4">
|
<Dialog.Footer class="mt-4">
|
||||||
<Button type="submit" {isLoading}>
|
<Button
|
||||||
{m.create()}
|
onclick={() => createSignupToken()}
|
||||||
</Button>
|
disabled={!selectedExpiration || usageLimit < 1}
|
||||||
</Dialog.Footer>
|
>
|
||||||
</form>
|
{m.create()}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<Qrcode
|
<Qrcode
|
||||||
@@ -196,8 +125,8 @@
|
|||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
|
|
||||||
<div class="text-muted-foreground mt-2 text-center text-sm">
|
<div class="text-muted-foreground mt-2 text-center text-sm">
|
||||||
<p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p>
|
<p>{m.usage_limit()}: {usageLimit}</p>
|
||||||
<p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p>
|
<p>{m.expiration()}: {selectedExpiration}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
cachedApplicationLogo,
|
cachedApplicationLogo,
|
||||||
cachedBackgroundImage,
|
cachedBackgroundImage,
|
||||||
cachedDefaultProfilePicture,
|
cachedDefaultProfilePicture,
|
||||||
cachedEmailLogo,
|
|
||||||
cachedProfilePicture
|
cachedProfilePicture
|
||||||
} from '$lib/utils/cached-image-util';
|
} from '$lib/utils/cached-image-util';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@@ -47,14 +46,6 @@ export default class AppConfigService extends APIService {
|
|||||||
cachedApplicationLogo.bustCache(light);
|
cachedApplicationLogo.bustCache(light);
|
||||||
};
|
};
|
||||||
|
|
||||||
updateEmailLogo = async (emailLogo: File) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', emailLogo);
|
|
||||||
|
|
||||||
await this.api.put(`/application-images/email`, formData);
|
|
||||||
cachedEmailLogo.bustCache();
|
|
||||||
};
|
|
||||||
|
|
||||||
updateDefaultProfilePicture = async (defaultProfilePicture: File) => {
|
updateDefaultProfilePicture = async (defaultProfilePicture: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', defaultProfilePicture);
|
formData.append('file', defaultProfilePicture);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||||
import type { SignupToken } from '$lib/types/signup-token.type';
|
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||||
import type { UserGroup } from '$lib/types/user-group.type';
|
import type { UserGroup } from '$lib/types/user-group.type';
|
||||||
import type { User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
import type { User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
||||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||||
@@ -76,12 +76,8 @@ export default class UserService extends APIService {
|
|||||||
return res.data.token;
|
return res.data.token;
|
||||||
};
|
};
|
||||||
|
|
||||||
createSignupToken = async (
|
createSignupToken = async (ttl: string | number, usageLimit: number) => {
|
||||||
ttl: string | number,
|
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit });
|
||||||
usageLimit: number,
|
|
||||||
userGroupIds: string[] = []
|
|
||||||
) => {
|
|
||||||
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit, userGroupIds });
|
|
||||||
return res.data.token;
|
return res.data.token;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,7 +111,7 @@ export default class UserService extends APIService {
|
|||||||
|
|
||||||
listSignupTokens = async (options?: ListRequestOptions) => {
|
listSignupTokens = async (options?: ListRequestOptions) => {
|
||||||
const res = await this.api.get('/signup-tokens', { params: options });
|
const res = await this.api.get('/signup-tokens', { params: options });
|
||||||
return res.data as Paginated<SignupToken>;
|
return res.data as Paginated<SignupTokenDto>;
|
||||||
};
|
};
|
||||||
|
|
||||||
deleteSignupToken = async (tokenId: string) => {
|
deleteSignupToken = async (tokenId: string) => {
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { UserGroup } from './user-group.type';
|
export interface SignupTokenDto {
|
||||||
|
|
||||||
export interface SignupToken {
|
|
||||||
id: string;
|
id: string;
|
||||||
token: string;
|
token: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
usageLimit: number;
|
usageLimit: number;
|
||||||
usageCount: number;
|
usageCount: number;
|
||||||
userGroups: UserGroup[];
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ export const cachedApplicationLogo: CachableImage = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cachedEmailLogo: CachableImage = {
|
|
||||||
getUrl: () => getCachedImageUrl(new URL('/api/application-images/email', window.location.origin)),
|
|
||||||
bustCache: () => bustImageCache(new URL('/api/application-images/email', window.location.origin))
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cachedDefaultProfilePicture: CachableImage = {
|
export const cachedDefaultProfilePicture: CachableImage = {
|
||||||
getUrl: () =>
|
getUrl: () =>
|
||||||
getCachedImageUrl(
|
getCachedImageUrl(
|
||||||
|
|||||||
@@ -42,7 +42,6 @@
|
|||||||
async function updateImages(
|
async function updateImages(
|
||||||
logoLight: File | undefined,
|
logoLight: File | undefined,
|
||||||
logoDark: File | undefined,
|
logoDark: File | undefined,
|
||||||
logoEmail: File | undefined,
|
|
||||||
defaultProfilePicture: File | null | undefined,
|
defaultProfilePicture: File | null | undefined,
|
||||||
backgroundImage: File | undefined,
|
backgroundImage: File | undefined,
|
||||||
favicon: File | undefined
|
favicon: File | undefined
|
||||||
@@ -57,10 +56,6 @@
|
|||||||
? appConfigService.updateLogo(logoDark, false)
|
? appConfigService.updateLogo(logoDark, false)
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
const emailLogoPromise = logoEmail
|
|
||||||
? appConfigService.updateEmailLogo(logoEmail)
|
|
||||||
: Promise.resolve();
|
|
||||||
|
|
||||||
const defaultProfilePicturePromise =
|
const defaultProfilePicturePromise =
|
||||||
defaultProfilePicture === null
|
defaultProfilePicture === null
|
||||||
? appConfigService.deleteDefaultProfilePicture()
|
? appConfigService.deleteDefaultProfilePicture()
|
||||||
@@ -75,7 +70,6 @@
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
lightLogoPromise,
|
lightLogoPromise,
|
||||||
darkLogoPromise,
|
darkLogoPromise,
|
||||||
emailLogoPromise,
|
|
||||||
defaultProfilePicturePromise,
|
defaultProfilePicturePromise,
|
||||||
backgroundImagePromise,
|
backgroundImagePromise,
|
||||||
faviconPromise
|
faviconPromise
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
|
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
|
||||||
import UserGroupInput from '$lib/components/form/user-group-input.svelte';
|
import SearchableMultiSelect from '$lib/components/form/searchable-multi-select.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||||
|
import { debounced } from '$lib/utils/debounce-util';
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -18,10 +21,14 @@
|
|||||||
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
|
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
|
const userGroupService = new UserGroupService();
|
||||||
|
|
||||||
|
let userGroups = $state<{ value: string; label: string }[]>([]);
|
||||||
|
let selectedGroups = $state<{ value: string; label: string }[]>([]);
|
||||||
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
|
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
|
||||||
let allowUserSignups = $state(appConfig.allowUserSignups);
|
let allowUserSignups = $state(appConfig.allowUserSignups);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
let isUserSearchLoading = $state(false);
|
||||||
|
|
||||||
const signupOptions = {
|
const signupOptions = {
|
||||||
disabled: {
|
disabled: {
|
||||||
@@ -38,11 +45,42 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function loadUserGroups(search?: string) {
|
||||||
|
userGroups = (await userGroupService.list({ search })).data.map((group) => ({
|
||||||
|
value: group.id,
|
||||||
|
label: group.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Ensure selected groups are still in the list
|
||||||
|
for (const selectedGroup of selectedGroups) {
|
||||||
|
if (!userGroups.some((g) => g.value === selectedGroup.value)) {
|
||||||
|
userGroups.push(selectedGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelectedGroups() {
|
||||||
|
selectedGroups = (
|
||||||
|
await Promise.all(
|
||||||
|
appConfig.signupDefaultUserGroupIDs.map((groupId) => userGroupService.get(groupId))
|
||||||
|
)
|
||||||
|
).map((group) => ({
|
||||||
|
value: group.id,
|
||||||
|
label: group.name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUserGroupSearch = debounced(
|
||||||
|
async (search: string) => await loadUserGroups(search),
|
||||||
|
300,
|
||||||
|
(loading) => (isUserSearchLoading = loading)
|
||||||
|
);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
await callback({
|
await callback({
|
||||||
allowUserSignups: allowUserSignups,
|
allowUserSignups: allowUserSignups,
|
||||||
signupDefaultUserGroupIDs: selectedGroupIds,
|
signupDefaultUserGroupIDs: selectedGroups.map((g) => g.value),
|
||||||
signupDefaultCustomClaims: customClaims
|
signupDefaultCustomClaims: customClaims
|
||||||
});
|
});
|
||||||
toast.success(m.user_creation_updated_successfully());
|
toast.success(m.user_creation_updated_successfully());
|
||||||
@@ -50,9 +88,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
loadSelectedGroups();
|
||||||
customClaims = appConfig.signupDefaultCustomClaims || [];
|
customClaims = appConfig.signupDefaultCustomClaims || [];
|
||||||
allowUserSignups = appConfig.allowUserSignups;
|
allowUserSignups = appConfig.allowUserSignups;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMount(() => loadUserGroups());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={preventDefault(onSubmit)}>
|
<form onsubmit={preventDefault(onSubmit)}>
|
||||||
@@ -111,7 +152,17 @@
|
|||||||
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||||
{m.user_creation_groups_description()}
|
{m.user_creation_groups_description()}
|
||||||
</p>
|
</p>
|
||||||
<UserGroupInput bind:selectedGroupIds />
|
<SearchableMultiSelect
|
||||||
|
id="default-groups"
|
||||||
|
items={userGroups}
|
||||||
|
oninput={(e) => onUserGroupSearch(e.currentTarget.value)}
|
||||||
|
selectedItems={selectedGroups.map((g) => g.value)}
|
||||||
|
onSelect={(selected) => {
|
||||||
|
selectedGroups = userGroups.filter((g) => selected.includes(g.value));
|
||||||
|
}}
|
||||||
|
isLoading={isUserSearchLoading}
|
||||||
|
disableInternalSearch
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label class="mb-0">{m.custom_claims()}</Label>
|
<Label class="mb-0">{m.custom_claims()}</Label>
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
import {
|
import {
|
||||||
cachedApplicationLogo,
|
cachedApplicationLogo,
|
||||||
cachedBackgroundImage,
|
cachedBackgroundImage,
|
||||||
cachedDefaultProfilePicture,
|
cachedDefaultProfilePicture
|
||||||
cachedEmailLogo
|
|
||||||
} from '$lib/utils/cached-image-util';
|
} from '$lib/utils/cached-image-util';
|
||||||
import ApplicationImage from './application-image.svelte';
|
import ApplicationImage from './application-image.svelte';
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
callback: (
|
callback: (
|
||||||
logoLight: File | undefined,
|
logoLight: File | undefined,
|
||||||
logoDark: File | undefined,
|
logoDark: File | undefined,
|
||||||
logoEmail: File | undefined,
|
|
||||||
defaultProfilePicture: File | null | undefined,
|
defaultProfilePicture: File | null | undefined,
|
||||||
backgroundImage: File | undefined,
|
backgroundImage: File | undefined,
|
||||||
favicon: File | undefined
|
favicon: File | undefined
|
||||||
@@ -24,7 +22,6 @@
|
|||||||
|
|
||||||
let logoLight = $state<File | undefined>();
|
let logoLight = $state<File | undefined>();
|
||||||
let logoDark = $state<File | undefined>();
|
let logoDark = $state<File | undefined>();
|
||||||
let logoEmail = $state<File | undefined>();
|
|
||||||
let defaultProfilePicture = $state<File | null | undefined>();
|
let defaultProfilePicture = $state<File | null | undefined>();
|
||||||
let backgroundImage = $state<File | undefined>();
|
let backgroundImage = $state<File | undefined>();
|
||||||
let favicon = $state<File | undefined>();
|
let favicon = $state<File | undefined>();
|
||||||
@@ -57,15 +54,6 @@
|
|||||||
imageURL={cachedApplicationLogo.getUrl(false)}
|
imageURL={cachedApplicationLogo.getUrl(false)}
|
||||||
forceColorScheme="dark"
|
forceColorScheme="dark"
|
||||||
/>
|
/>
|
||||||
<ApplicationImage
|
|
||||||
id="logo-email"
|
|
||||||
imageClass="size-24"
|
|
||||||
label={m.email_logo()}
|
|
||||||
bind:image={logoEmail}
|
|
||||||
imageURL={cachedEmailLogo.getUrl()}
|
|
||||||
accept="image/png, image/jpeg"
|
|
||||||
forceColorScheme="light"
|
|
||||||
/>
|
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
id="default-profile-picture"
|
id="default-profile-picture"
|
||||||
imageClass="size-24"
|
imageClass="size-24"
|
||||||
@@ -87,8 +75,7 @@
|
|||||||
<Button
|
<Button
|
||||||
class="mt-5"
|
class="mt-5"
|
||||||
usePromiseLoading
|
usePromiseLoading
|
||||||
onclick={() =>
|
onclick={() => callback(logoLight, logoDark, defaultProfilePicture, backgroundImage, favicon)}
|
||||||
callback(logoLight, logoDark, logoEmail, defaultProfilePicture, backgroundImage, favicon)}
|
|
||||||
>{m.save()}</Button
|
>{m.save()}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,7 +64,8 @@
|
|||||||
<DropdownButton.Main disabled={false} onclick={() => (expandAddUser = true)}>
|
<DropdownButton.Main disabled={false} onclick={() => (expandAddUser = true)}>
|
||||||
{selectedCreateOptions}
|
{selectedCreateOptions}
|
||||||
</DropdownButton.Main>
|
</DropdownButton.Main>
|
||||||
<DropdownButton.DropdownTrigger aria-label="Create options">
|
|
||||||
|
<DropdownButton.DropdownTrigger>
|
||||||
<DropdownButton.Trigger class="border-l" />
|
<DropdownButton.Trigger class="border-l" />
|
||||||
</DropdownButton.DropdownTrigger>
|
</DropdownButton.DropdownTrigger>
|
||||||
</DropdownButton.Root>
|
</DropdownButton.Root>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 16 KiB |
BIN
tests/assets/nextcloud-logo.png
Normal file
BIN
tests/assets/nextcloud-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -66,7 +66,7 @@ export const oidcClients = {
|
|||||||
|
|
||||||
export const userGroups = {
|
export const userGroups = {
|
||||||
developers: {
|
developers: {
|
||||||
id: 'c7ae7c01-28a3-4f3c-9572-1ee734ea8368',
|
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
|
||||||
friendlyName: 'Developers',
|
friendlyName: 'Developers',
|
||||||
name: 'developers'
|
name: 'developers'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -116,49 +116,30 @@ test('Update email configuration', async ({ page }) => {
|
|||||||
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
|
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Update application images', () => {
|
test('Update application images', async ({ page }) => {
|
||||||
test.beforeEach(async ({ page }) => {
|
await page.getByRole('button', { name: 'Expand card' }).nth(4).click();
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(4).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should upload images', async ({ page }) => {
|
await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico');
|
||||||
await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico');
|
await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png');
|
||||||
await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png');
|
await page.getByLabel('Dark Mode Logo').setInputFiles('assets/nextcloud-logo.png');
|
||||||
await page.getByLabel('Dark Mode Logo').setInputFiles('assets/cloud-logo.png');
|
await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png');
|
||||||
await page.getByLabel('Email Logo').setInputFiles('assets/pingvin-share-logo.png');
|
await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg');
|
||||||
await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png');
|
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||||
await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg');
|
|
||||||
await page.getByRole('button', { name: 'Save' }).last().click();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
'Images updated successfully. It may take a few minutes to update.'
|
'Images updated successfully. It may take a few minutes to update.'
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-images/favicon')
|
.get('/api/application-images/favicon')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-images/logo?light=true')
|
.get('/api/application-images/logo?light=true')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-images/logo?light=false')
|
.get('/api/application-images/logo?light=false')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-images/email')
|
.get('/api/application-images/background')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
});
|
||||||
.get('/api/application-images/background')
|
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should only allow png/jpeg for email logo', async ({ page }) => {
|
|
||||||
const emailLogoInput = page.getByLabel('Email Logo');
|
|
||||||
|
|
||||||
await emailLogoInput.setInputFiles('assets/cloud-logo.svg');
|
|
||||||
await page.getByRole('button', { name: 'Save' }).last().click();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-type="error"]')).toHaveText(
|
|
||||||
'File must be of type .png or .jpg/jpeg'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ test('Edit OIDC client', async ({ page }) => {
|
|||||||
await page.getByLabel('Name').fill('Nextcloud updated');
|
await page.getByLabel('Name').fill('Nextcloud updated');
|
||||||
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
||||||
await page.locator('[role="tab"][data-value="light-logo"]').first().click();
|
await page.locator('[role="tab"][data-value="light-logo"]').first().click();
|
||||||
await page.setInputFiles('#oidc-client-logo-light', 'assets/cloud-logo.png');
|
await page.setInputFiles('#oidc-client-logo-light', 'assets/nextcloud-logo.png');
|
||||||
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
|
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
|
||||||
await page.setInputFiles('#oidc-client-logo-dark', 'assets/cloud-logo.png');
|
await page.setInputFiles('#oidc-client-logo-dark', 'assets/nextcloud-logo.png');
|
||||||
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import test, { expect, type Page } from '@playwright/test';
|
import test, { expect, type Page } from '@playwright/test';
|
||||||
import { signupTokens, userGroups, users } from '../data';
|
import { signupTokens, users } from '../data';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
import passkeyUtil from '../utils/passkey.util';
|
import passkeyUtil from '../utils/passkey.util';
|
||||||
|
|
||||||
async function setSignupMode(
|
async function setSignupMode(page: Page, mode: 'Disabled' | 'Signup with token' | 'Open Signup') {
|
||||||
page: Page,
|
|
||||||
mode: 'Disabled' | 'Signup with token' | 'Open Signup',
|
|
||||||
signout = true
|
|
||||||
) {
|
|
||||||
await page.goto('/settings/admin/application-configuration');
|
await page.goto('/settings/admin/application-configuration');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||||
@@ -19,51 +15,10 @@ async function setSignupMode(
|
|||||||
'User creation settings updated successfully.'
|
'User creation settings updated successfully.'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (signout) {
|
await page.context().clearCookies();
|
||||||
await page.context().clearCookies();
|
await page.goto('/login');
|
||||||
await page.goto('/login');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Signup Token Creation', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await cleanupBackend();
|
|
||||||
await setSignupMode(page, 'Signup with token', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Create signup token', async ({ page }) => {
|
|
||||||
await page.goto('/settings/admin/users');
|
|
||||||
|
|
||||||
await page.getByLabel('Create options').getByRole('button').click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Create Signup Token' }).click();
|
|
||||||
await page.getByLabel('Expiration').click();
|
|
||||||
await page.getByRole('option', { name: 'week' }).click();
|
|
||||||
|
|
||||||
await page.getByLabel('Usage Limit').fill('8');
|
|
||||||
|
|
||||||
await page.getByLabel('User Groups').click();
|
|
||||||
await page.getByRole('option', { name: userGroups.developers.name }).click();
|
|
||||||
await page.getByRole('option', { name: userGroups.designers.name }).click();
|
|
||||||
await page.getByLabel('User Groups').click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
|
||||||
|
|
||||||
await page.getByLabel('Create options').getByRole('button').click();
|
|
||||||
await page.getByRole('menuitem', { name: 'View Active Signup Tokens' }).click();
|
|
||||||
await page.getByLabel('Manage Signup Tokens').getByRole('button', { name: 'View' }).click();
|
|
||||||
|
|
||||||
await page.getByRole('menuitemcheckbox', { name: 'User Groups' }).click();
|
|
||||||
|
|
||||||
const row = page.getByRole('row').last();
|
|
||||||
await expect(row.getByRole('cell', { name: '0 of 8' })).toBeVisible();
|
|
||||||
const dateInAWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US');
|
|
||||||
await expect(row.getByRole('cell', { name: dateInAWeek })).toBeVisible();
|
|
||||||
await expect(row.getByRole('cell', { name: userGroups.developers.name })).toBeVisible();
|
|
||||||
await expect(row.getByRole('cell', { name: userGroups.designers.name })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Initial User Signup', () => {
|
test.describe('Initial User Signup', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.context().clearCookies();
|
await page.context().clearCookies();
|
||||||
@@ -119,9 +74,6 @@ test.describe('User Signup', () => {
|
|||||||
|
|
||||||
await page.waitForURL('/signup/add-passkey');
|
await page.waitForURL('/signup/add-passkey');
|
||||||
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
||||||
|
|
||||||
const response = await page.request.get('/api/users/me').then((res) => res.json());
|
|
||||||
expect(response.userGroups.map((g) => g.id)).toContain(userGroups.developers.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Signup with token - invalid token shows error', async ({ page }) => {
|
test('Signup with token - invalid token shows error', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user