mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-23 17:25:22 +03:00
Compare commits
5 Commits
breaking/v
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61c784988 | ||
|
|
e358c433f0 | ||
|
|
08e4ffeb60 | ||
|
|
59ca6b26ac | ||
|
|
f5da11b99b |
@@ -4,7 +4,7 @@ Pocket ID is a simple OIDC provider that allows users to authenticate with their
|
||||
|
||||
→ Try out the [Demo](https://demo.pocket-id.org)
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
|
||||
<img src="https://github.com/user-attachments/assets/1e99ba44-76da-4b47-9b8a-dbe9b7f84512" width="1200"/>
|
||||
|
||||
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
|
||||
// Previous versions of images
|
||||
// If these are found, they are deleted
|
||||
legacyImageHashes := imageHashMap{
|
||||
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
|
||||
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
|
||||
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
|
||||
}
|
||||
|
||||
sourceFiles, err := resources.FS.ReadDir("images")
|
||||
|
||||
@@ -198,6 +198,7 @@ func initLogger(r *gin.Engine) {
|
||||
"GET /api/application-images/logo",
|
||||
"GET /api/application-images/background",
|
||||
"GET /api/application-images/favicon",
|
||||
"GET /api/application-images/email",
|
||||
"GET /_app",
|
||||
"GET /fonts",
|
||||
"GET /healthz",
|
||||
|
||||
@@ -68,6 +68,7 @@ type EnvConfigSchema struct {
|
||||
TracingEnabled bool `env:"TRACING_ENABLED"`
|
||||
LogJSON bool `env:"LOG_JSON"`
|
||||
TrustProxy bool `env:"TRUST_PROXY"`
|
||||
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
|
||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||
@@ -85,16 +86,17 @@ func init() {
|
||||
|
||||
func defaultConfig() EnvConfigSchema {
|
||||
return EnvConfigSchema{
|
||||
AppEnv: AppEnvProduction,
|
||||
LogLevel: "info",
|
||||
DbProvider: "sqlite",
|
||||
FileBackend: "filesystem",
|
||||
KeysPath: "data/keys",
|
||||
AppURL: AppUrl,
|
||||
Port: "1411",
|
||||
Host: "0.0.0.0",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
AppEnv: AppEnvProduction,
|
||||
LogLevel: "info",
|
||||
DbProvider: "sqlite",
|
||||
FileBackend: "filesystem",
|
||||
KeysPath: "data/keys",
|
||||
AuditLogRetentionDays: 90,
|
||||
AppURL: AppUrl,
|
||||
Port: "1411",
|
||||
Host: "0.0.0.0",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +216,10 @@ func validateEnvConfig(config *EnvConfigSchema) error {
|
||||
|
||||
}
|
||||
|
||||
if config.AuditLogRetentionDays <= 0 {
|
||||
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
@@ -187,6 +187,41 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
assert.False(t, EnvConfig.AnalyticsDisabled)
|
||||
})
|
||||
|
||||
t.Run("should default audit log retention days to 90", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 90, EnvConfig.AuditLogRetentionDays)
|
||||
})
|
||||
|
||||
t.Run("should parse audit log retention days override", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("AUDIT_LOG_RETENTION_DAYS", "365")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 365, EnvConfig.AuditLogRetentionDays)
|
||||
})
|
||||
|
||||
t.Run("should fail when AUDIT_LOG_RETENTION_DAYS is non-positive", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("AUDIT_LOG_RETENTION_DAYS", "0")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "AUDIT_LOG_RETENTION_DAYS must be greater than 0")
|
||||
})
|
||||
|
||||
t.Run("should parse string environment variables correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "postgres")
|
||||
|
||||
@@ -23,11 +23,13 @@ func NewAppImagesController(
|
||||
}
|
||||
|
||||
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/favicon", controller.getFaviconHandler)
|
||||
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
|
||||
|
||||
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/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
||||
@@ -59,6 +61,18 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
|
||||
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
|
||||
// @Summary Get background image
|
||||
// @Description Get the background image for the application
|
||||
@@ -124,6 +138,37 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
|
||||
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
|
||||
// @Summary Update background image
|
||||
// @Description Update the application background image
|
||||
|
||||
@@ -545,7 +545,7 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
||||
ttl = defaultSignupTokenDuration
|
||||
}
|
||||
|
||||
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
|
||||
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
|
||||
@@ -18,6 +18,7 @@ type OidcClientDto struct {
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
}
|
||||
|
||||
type OidcClientWithAllowedUserGroupsDto struct {
|
||||
@@ -43,6 +44,7 @@ type OidcClientUpdateDto struct {
|
||||
HasDarkLogo bool `json:"hasDarkLogo"`
|
||||
LogoURL *string `json:"logoUrl"`
|
||||
DarkLogoURL *string `json:"darkLogoUrl"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
|
||||
@@ -6,8 +6,9 @@ import (
|
||||
)
|
||||
|
||||
type SignupTokenCreateDto struct {
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
UserGroupIDs []string `json:"userGroupIds"`
|
||||
}
|
||||
|
||||
type SignupTokenDto struct {
|
||||
@@ -16,5 +17,6 @@ type SignupTokenDto struct {
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
UserGroups []UserGroupDto `json:"userGroups"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -23,15 +23,16 @@ type UserDto struct {
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,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"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
LdapID string `json:"-"`
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,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"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
UserGroupIds []string `json:"userGroupIds"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (u UserCreateDto) Validate() error {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
@@ -119,11 +120,13 @@ func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearAuditLogs deletes audit logs older than 90 days
|
||||
// ClearAuditLogs deletes audit logs older than the configured retention window
|
||||
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
||||
cutoff := time.Now().AddDate(0, 0, -common.EnvConfig.AuditLogRetentionDays)
|
||||
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
|
||||
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(cutoff))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ type OidcClient struct {
|
||||
RequiresReauthentication bool `sortable:"true" filterable:"true"`
|
||||
Credentials OidcClientCredentials
|
||||
LaunchURL *string
|
||||
IsGroupRestricted bool
|
||||
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID *string
|
||||
|
||||
@@ -13,6 +13,7 @@ type SignupToken struct {
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||
UsageCount int `json:"usageCount" sortable:"true"`
|
||||
UserGroups []UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsExpired() bool {
|
||||
|
||||
@@ -344,6 +344,9 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 1,
|
||||
UsageCount: 0,
|
||||
UserGroups: []model.UserGroup{
|
||||
userGroups[0],
|
||||
},
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
|
||||
@@ -78,7 +78,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
|
||||
|
||||
data := &email.TemplateData[V]{
|
||||
AppName: dbConfig.AppName.Value,
|
||||
LogoURL: common.EnvConfig.AppURL + "/api/application-images/logo",
|
||||
LogoURL: common.EnvConfig.AppURL + "/api/application-images/email",
|
||||
Data: tData,
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID,
|
||||
|
||||
// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
|
||||
func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
|
||||
if len(client.AllowedUserGroups) == 0 {
|
||||
if !client.IsGroupRestricted {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -816,6 +816,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||
client.RequiresReauthentication = input.RequiresReauthentication
|
||||
client.LaunchURL = input.LaunchURL
|
||||
client.IsGroupRestricted = input.IsGroupRestricted
|
||||
|
||||
// Credentials
|
||||
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
|
||||
|
||||
@@ -253,6 +253,18 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
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{
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
@@ -262,6 +274,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
IsAdmin: input.IsAdmin,
|
||||
Locale: input.Locale,
|
||||
Disabled: input.Disabled,
|
||||
UserGroups: userGroups,
|
||||
}
|
||||
if input.LdapID != "" {
|
||||
user.LdapID = &input.LdapID
|
||||
@@ -285,7 +298,13 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
|
||||
// Apply default groups and claims for new non-LDAP users
|
||||
if !isLdapSync {
|
||||
if err := s.applySignupDefaults(ctx, &user, tx); err != nil {
|
||||
if len(input.UserGroupIds) == 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -293,10 +312,9 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Apply default user groups
|
||||
var groupIDs []string
|
||||
v := config.SignupDefaultUserGroupIDs.Value
|
||||
if v != "" && v != "[]" {
|
||||
@@ -323,10 +341,14 @@ func (s *UserService) applySignupDefaults(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
|
||||
v = config.SignupDefaultCustomClaims.Value
|
||||
v := config.SignupDefaultCustomClaims.Value
|
||||
if v != "" && v != "[]" {
|
||||
err := json.Unmarshal([]byte(v), &claims)
|
||||
if err != nil {
|
||||
@@ -727,12 +749,22 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int) (model.SignupToken, error) {
|
||||
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) {
|
||||
signupToken, err := NewSignupToken(ttl, usageLimit)
|
||||
if err != nil {
|
||||
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
|
||||
if err != nil {
|
||||
return model.SignupToken{}, err
|
||||
@@ -755,9 +787,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
||||
}
|
||||
|
||||
var signupToken model.SignupToken
|
||||
var userGroupIDs []string
|
||||
if tokenProvided {
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Preload("UserGroups").
|
||||
Where("token = ?", signupData.Token).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&signupToken).
|
||||
@@ -772,14 +806,19 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
||||
if !signupToken.IsValid() {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
for _, group := range signupToken.UserGroups {
|
||||
userGroupIDs = append(userGroupIDs, group.ID)
|
||||
}
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
Username: signupData.Username,
|
||||
Email: signupData.Email,
|
||||
FirstName: signupData.FirstName,
|
||||
LastName: signupData.LastName,
|
||||
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||
Username: signupData.Username,
|
||||
Email: signupData.Email,
|
||||
FirstName: signupData.FirstName,
|
||||
LastName: signupData.LastName,
|
||||
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||
UserGroupIds: userGroupIDs,
|
||||
}
|
||||
|
||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||
@@ -820,7 +859,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) {
|
||||
var tokens []model.SignupToken
|
||||
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
|
||||
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
|
||||
|
||||
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
|
||||
return tokens, pagination, err
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 221 KiB |
BIN
backend/resources/images/logoEmail.png
Normal file
BIN
backend/resources/images/logoEmail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 566 B |
@@ -0,0 +1 @@
|
||||
DROP TABLE signup_tokens_user_groups;
|
||||
@@ -0,0 +1,8 @@
|
||||
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 +1,7 @@
|
||||
ALTER TABLE one_time_access_tokens DROP COLUMN device_token;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE one_time_access_tokens DROP COLUMN device_token;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE signup_tokens_user_groups;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
@@ -0,0 +1,7 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE oidc_clients DROP COLUMN is_group_restricted;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,13 @@
|
||||
PRAGMA foreign_keys= OFF;
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE oidc_clients
|
||||
ADD COLUMN is_group_restricted BOOLEAN NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE oidc_clients
|
||||
SET is_group_restricted = (SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
|
||||
FROM oidc_client_user_groups
|
||||
WHERE oidc_client_user_groups.oidc_client_id = oidc_clients.id);
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys= ON;
|
||||
@@ -95,7 +95,7 @@
|
||||
"settings": "Settings",
|
||||
"update_pocket_id": "Update Pocket ID",
|
||||
"powered_by": "Powered by",
|
||||
"see_your_account_activities_from_the_last_3_months": "See your account activities from the last 3 months.",
|
||||
"see_your_recent_account_activities": "See your account activities within the configured retention period.",
|
||||
"time": "Time",
|
||||
"event": "Event",
|
||||
"approximate_location": "Approximate Location",
|
||||
@@ -301,16 +301,20 @@
|
||||
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
|
||||
"generate": "Generate",
|
||||
"new_client_secret_created_successfully": "New client secret created successfully",
|
||||
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
|
||||
"oidc_client_name": "OIDC Client {name}",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client secret",
|
||||
"show_more_details": "Show more details",
|
||||
"allowed_user_groups": "Allowed User Groups",
|
||||
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
|
||||
"allowed_user_groups_description": "Select user groups to restrict signing in to this client to only users in these groups.",
|
||||
"allowed_user_groups_status_unrestricted_description": "No user group restrictions are applied. Any user can sign in to this client.",
|
||||
"unrestrict": "Unrestrict",
|
||||
"restrict": "Restrict",
|
||||
"user_groups_restriction_updated_successfully": "User groups restriction updated successfully",
|
||||
"favicon": "Favicon",
|
||||
"light_mode_logo": "Light Mode Logo",
|
||||
"dark_mode_logo": "Dark Mode Logo",
|
||||
"email_logo": "Email Logo",
|
||||
"background_image": "Background Image",
|
||||
"language": "Language",
|
||||
"reset_profile_picture_question": "Reset profile picture?",
|
||||
@@ -327,7 +331,7 @@
|
||||
"all_clients": "All Clients",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "Global Audit Log",
|
||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
||||
"see_all_recent_account_activities": "View the account activities of all users during the set retention period.",
|
||||
"token_sign_in": "Token Sign In",
|
||||
"client_authorization": "Client Authorization",
|
||||
"new_client_authorization": "New Client Authorization",
|
||||
@@ -469,5 +473,6 @@
|
||||
"default_profile_picture": "Default Profile Picture",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
"system": "System",
|
||||
"signup_token_user_groups_description": "Automatically assign these groups to users who sign up using this token."
|
||||
}
|
||||
|
||||
@@ -232,22 +232,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-bg-container {
|
||||
@keyframes bg-zoom {
|
||||
0% {
|
||||
left: 0;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
100% {
|
||||
left: 650px;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-bg-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
animation: slide-bg-container 0.6s cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
||||
.animate-bg-zoom {
|
||||
transform-origin: center;
|
||||
will-change: transform;
|
||||
animation: bg-zoom 0.7s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes delayed-fade {
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
title,
|
||||
description,
|
||||
defaultExpanded = false,
|
||||
forcedExpanded,
|
||||
button,
|
||||
icon,
|
||||
children
|
||||
}: {
|
||||
@@ -19,7 +21,9 @@
|
||||
title: string;
|
||||
description?: string;
|
||||
defaultExpanded?: boolean;
|
||||
forcedExpanded?: boolean;
|
||||
icon?: typeof IconType;
|
||||
button?: Snippet;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
@@ -47,6 +51,12 @@
|
||||
}
|
||||
loadExpandedState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (forcedExpanded !== undefined) {
|
||||
expanded = forcedExpanded;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
@@ -63,11 +73,18 @@
|
||||
<Card.Description>{description}</Card.Description>
|
||||
{/if}
|
||||
</div>
|
||||
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
|
||||
<LucideChevronDown
|
||||
class={cn('size-5 transition-transform duration-200', expanded && 'rotate-180 transform')}
|
||||
/>
|
||||
</Button>
|
||||
{#if button}
|
||||
{@render button()}
|
||||
{:else}
|
||||
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
|
||||
<LucideChevronDown
|
||||
class={cn(
|
||||
'size-5 transition-transform duration-200',
|
||||
expanded && 'rotate-180 transform'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if expanded}
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
type WithoutChildren = {
|
||||
children?: undefined;
|
||||
input?: FormInput<string | boolean | number | Date | undefined>;
|
||||
labelFor?: never;
|
||||
};
|
||||
type WithChildren = {
|
||||
children: Snippet;
|
||||
input?: any;
|
||||
labelFor?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
input = $bindable(),
|
||||
label,
|
||||
@@ -18,25 +29,25 @@
|
||||
type = 'text',
|
||||
children,
|
||||
onInput,
|
||||
labelFor,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
input?: FormInput<string | boolean | number | Date | undefined>;
|
||||
label?: string;
|
||||
description?: string;
|
||||
docsLink?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
|
||||
onInput?: (e: FormInputEvent) => void;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
}: HTMLAttributes<HTMLDivElement> &
|
||||
(WithChildren | WithoutChildren) & {
|
||||
label?: string;
|
||||
description?: string;
|
||||
docsLink?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
|
||||
onInput?: (e: FormInputEvent) => void;
|
||||
} = $props();
|
||||
|
||||
const id = label?.toLowerCase().replace(/ /g, '-');
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
{#if label}
|
||||
<Label required={input?.required} class="mb-0" for={id}>{label}</Label>
|
||||
<Label required={input?.required} class="mb-0" for={labelFor ?? id}>{label}</Label>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="text-muted-foreground mt-1 text-xs">
|
||||
|
||||
50
frontend/src/lib/components/form/user-group-input.svelte
Normal file
50
frontend/src/lib/components/form/user-group-input.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<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
|
||||
/>
|
||||
@@ -19,7 +19,7 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
|
||||
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-3 lg:mt-8 pr-2 lg:pr-3' : 'border-b'}">
|
||||
<div
|
||||
class="{!isAuthPage
|
||||
? 'max-w-[1640px]'
|
||||
|
||||
@@ -48,20 +48,16 @@
|
||||
{#if isDesktop.current}
|
||||
<div class="h-screen items-center overflow-hidden text-center">
|
||||
<div
|
||||
class="relative z-10 flex h-full w-[650px] p-16 {cn(
|
||||
showAlternativeSignInMethodButton && 'pb-0',
|
||||
animate && 'animate-delayed-fade'
|
||||
class="relative z-10 flex h-full w-[650px] 2xl:w-[800px] p-16 {cn(
|
||||
showAlternativeSignInMethodButton && 'pb-0'
|
||||
)}"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col overflow-hidden">
|
||||
<div class="relative flex flex-grow flex-col items-center justify-center overflow-auto">
|
||||
<div class="relative flex grow flex-col items-center justify-center overflow-auto">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if showAlternativeSignInMethodButton}
|
||||
<div
|
||||
class="mb-4 flex items-center justify-center"
|
||||
style={animate ? 'animation-delay: 500ms;' : ''}
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-center">
|
||||
<a
|
||||
href={alternativeSignInButton.href}
|
||||
class="text-muted-foreground text-xs transition-colors hover:underline"
|
||||
@@ -73,13 +69,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background image with slide animation -->
|
||||
<div class="{cn(animate && 'animate-slide-bg-container')} absolute top-0 right-0 bottom-0 z-0">
|
||||
<!-- Background image -->
|
||||
<div class="absolute top-0 right-0 left-500px bottom-0 z-0 overflow-hidden rounded-[40px] m-6">
|
||||
<img
|
||||
src={cachedBackgroundImage.getUrl()}
|
||||
class="h-screen rounded-l-[60px] object-cover {animate
|
||||
? 'w-full'
|
||||
: 'w-[calc(100vw-650px)]'}"
|
||||
class="{cn(
|
||||
animate && 'animate-bg-zoom'
|
||||
)} h-screen object-cover w-[calc(100vw-650px)] 2xl:w-[calc(100vw-800px)]"
|
||||
alt={m.login_background()}
|
||||
/>
|
||||
</div>
|
||||
@@ -89,7 +85,7 @@
|
||||
class="flex h-screen items-center justify-center bg-cover bg-center text-center"
|
||||
style="background-image: url({cachedBackgroundImage.getUrl()});"
|
||||
>
|
||||
<Card.Root class="mx-3 w-full max-w-md" style={animate ? 'animation-delay: 200ms;' : ''}>
|
||||
<Card.Root class="mx-3 w-full max-w-md">
|
||||
<Card.CardContent
|
||||
class="px-4 py-10 sm:p-10 {showAlternativeSignInMethodButton ? 'pb-3 sm:pb-3' : ''}"
|
||||
>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
AdvancedTableColumn,
|
||||
CreateAdvancedTableActions
|
||||
} from '$lib/types/advanced-table.type';
|
||||
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||
import type { SignupToken } from '$lib/types/signup-token.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { Copy, Trash2 } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -23,14 +23,14 @@
|
||||
} = $props();
|
||||
|
||||
const userService = new UserService();
|
||||
let tableRef: AdvancedTable<SignupTokenDto>;
|
||||
let tableRef: AdvancedTable<SignupToken>;
|
||||
|
||||
function formatDate(dateStr: string | undefined) {
|
||||
if (!dateStr) return m.never();
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
async function deleteToken(token: SignupTokenDto) {
|
||||
async function deleteToken(token: SignupToken) {
|
||||
openConfirmDialog({
|
||||
title: m.delete_signup_token(),
|
||||
message: m.are_you_sure_you_want_to_delete_this_signup_token(),
|
||||
@@ -58,11 +58,11 @@
|
||||
return new Date(expiresAt) < new Date();
|
||||
}
|
||||
|
||||
function isTokenUsedUp(token: SignupTokenDto) {
|
||||
function isTokenUsedUp(token: SignupToken) {
|
||||
return token.usageCount >= token.usageLimit;
|
||||
}
|
||||
|
||||
function getTokenStatus(token: SignupTokenDto) {
|
||||
function getTokenStatus(token: SignupToken) {
|
||||
if (isTokenExpired(token.expiresAt)) return 'expired';
|
||||
if (isTokenUsedUp(token)) return 'used-up';
|
||||
return 'active';
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function copySignupLink(token: SignupTokenDto) {
|
||||
function copySignupLink(token: SignupToken) {
|
||||
const signupLink = `${page.url.origin}/st/${token.token}`;
|
||||
navigator.clipboard
|
||||
.writeText(signupLink)
|
||||
@@ -91,7 +91,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
const columns: AdvancedTableColumn<SignupTokenDto>[] = [
|
||||
const columns: AdvancedTableColumn<SignupToken>[] = [
|
||||
{ label: m.token(), column: 'token', cell: TokenCell },
|
||||
{ label: m.status(), key: 'status', cell: StatusCell },
|
||||
{
|
||||
@@ -106,7 +106,12 @@
|
||||
sortable: true,
|
||||
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(),
|
||||
column: 'createdAt',
|
||||
@@ -116,7 +121,7 @@
|
||||
}
|
||||
];
|
||||
|
||||
const actions: CreateAdvancedTableActions<SignupTokenDto> = (_) => [
|
||||
const actions: CreateAdvancedTableActions<SignupToken> = (_) => [
|
||||
{
|
||||
label: m.copy(),
|
||||
icon: Copy,
|
||||
@@ -131,13 +136,13 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
{#snippet TokenCell({ item }: { item: SignupTokenDto })}
|
||||
{#snippet TokenCell({ item }: { item: SignupToken })}
|
||||
<span class="font-mono text-xs">
|
||||
{item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet StatusCell({ item }: { item: SignupTokenDto })}
|
||||
{#snippet StatusCell({ item }: { item: SignupToken })}
|
||||
{@const status = getTokenStatus(item)}
|
||||
{@const statusBadge = getStatusBadge(status)}
|
||||
<Badge class="rounded-full" variant={statusBadge.variant}>
|
||||
@@ -145,7 +150,7 @@
|
||||
</Badge>
|
||||
{/snippet}
|
||||
|
||||
{#snippet UsageCell({ item }: { item: SignupTokenDto })}
|
||||
{#snippet UsageCell({ item }: { item: SignupToken })}
|
||||
<div class="flex items-center gap-1">
|
||||
{item.usageCount}
|
||||
{m.of()}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
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 { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
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 { m } from '$lib/paraglide/messages';
|
||||
import AppConfigService from '$lib/services/app-config-service';
|
||||
import UserService from '$lib/services/user-service';
|
||||
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 { onMount } from 'svelte';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
let {
|
||||
open = $bindable()
|
||||
@@ -19,29 +25,74 @@
|
||||
} = $props();
|
||||
|
||||
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 signupLink: string | null = $state(null);
|
||||
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_day());
|
||||
let usageLimit: number = $state(1);
|
||||
let createdSignupData: SignupTokenForm | null = $state(null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
let availableExpirations = {
|
||||
[m.one_hour()]: 60 * 60,
|
||||
[m.twelve_hours()]: 60 * 60 * 12,
|
||||
[m.one_day()]: 60 * 60 * 24,
|
||||
[m.one_week()]: 60 * 60 * 24 * 7,
|
||||
[m.one_month()]: 60 * 60 * 24 * 30
|
||||
};
|
||||
let defaultUserGroupIds: string[] = [];
|
||||
|
||||
function getExpirationLabel(ttl: number) {
|
||||
return availableExpirations.find((exp) => exp.value === ttl)?.label ?? '';
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
form.setValue('userGroupIds', defaultUserGroupIds);
|
||||
}
|
||||
|
||||
async function createSignupToken() {
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
signupToken = await userService.createSignupToken(
|
||||
availableExpirations[selectedExpiration],
|
||||
usageLimit
|
||||
data.ttl,
|
||||
data.usageLimit,
|
||||
data.userGroupIds
|
||||
);
|
||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||
createdSignupData = data;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +101,22 @@
|
||||
if (!isOpen) {
|
||||
signupToken = null;
|
||||
signupLink = null;
|
||||
selectedExpiration = m.one_day();
|
||||
usageLimit = 1;
|
||||
createdSignupData = null;
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
appConfigService
|
||||
.list(true)
|
||||
.then((response) => {
|
||||
const responseGroupIds = response.signupDefaultUserGroupIDs || [];
|
||||
defaultUserGroupIds = responseGroupIds;
|
||||
initialFormValues.userGroupIds = responseGroupIds;
|
||||
form.setValue('userGroupIds', responseGroupIds);
|
||||
})
|
||||
.catch(axiosErrorToast);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
@@ -66,49 +129,57 @@
|
||||
</Dialog.Header>
|
||||
|
||||
{#if signupToken === null}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label for="expiration">{m.expiration()}</Label>
|
||||
<form class="space-y-4" onsubmit={preventDefault(createSignupToken)}>
|
||||
<FormInput labelFor="expiration" label={m.expiration()} input={$inputs.ttl}>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={Object.keys(availableExpirations)[0]}
|
||||
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
|
||||
value={$inputs.ttl.value.toString()}
|
||||
onValueChange={(v) => v && form.setValue('ttl', Number(v))}
|
||||
>
|
||||
<Select.Trigger id="expiration" class="h-9 w-full">
|
||||
{selectedExpiration}
|
||||
{getExpirationLabel($inputs.ttl.value)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(availableExpirations) as key}
|
||||
<Select.Item value={key}>{key}</Select.Item>
|
||||
{#each availableExpirations as expiration}
|
||||
<Select.Item value={expiration.value.toString()}>
|
||||
{expiration.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-0" for="usage-limit">{m.usage_limit()}</Label>
|
||||
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||
{m.number_of_times_token_can_be_used()}
|
||||
</p>
|
||||
{#if $inputs.ttl.error}
|
||||
<p class="text-destructive mt-1 text-xs">{$inputs.ttl.error}</p>
|
||||
{/if}
|
||||
</FormInput>
|
||||
<FormInput
|
||||
labelFor="usage-limit"
|
||||
label={m.usage_limit()}
|
||||
description={m.number_of_times_token_can_be_used()}
|
||||
input={$inputs.usageLimit}
|
||||
>
|
||||
<Input
|
||||
id="usage-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
bind:value={usageLimit}
|
||||
bind:value={$inputs.usageLimit.value}
|
||||
aria-invalid={$inputs.usageLimit.error ? 'true' : undefined}
|
||||
class="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button
|
||||
onclick={() => createSignupToken()}
|
||||
disabled={!selectedExpiration || usageLimit < 1}
|
||||
</FormInput>
|
||||
<FormInput
|
||||
labelFor="default-groups"
|
||||
label={m.user_groups()}
|
||||
description={m.signup_token_user_groups_description()}
|
||||
input={$inputs.userGroupIds}
|
||||
>
|
||||
{m.create()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
<UserGroupInput bind:selectedGroupIds={$inputs.userGroupIds.value} />
|
||||
</FormInput>
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="submit" {isLoading}>
|
||||
{m.create()}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Qrcode
|
||||
@@ -125,8 +196,8 @@
|
||||
</CopyToClipboard>
|
||||
|
||||
<div class="text-muted-foreground mt-2 text-center text-sm">
|
||||
<p>{m.usage_limit()}: {usageLimit}</p>
|
||||
<p>{m.expiration()}: {selectedExpiration}</p>
|
||||
<p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p>
|
||||
<p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
cachedApplicationLogo,
|
||||
cachedBackgroundImage,
|
||||
cachedDefaultProfilePicture,
|
||||
cachedEmailLogo,
|
||||
cachedProfilePicture
|
||||
} from '$lib/utils/cached-image-util';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -46,6 +47,14 @@ export default class AppConfigService extends APIService {
|
||||
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) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', defaultProfilePicture);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||
import type { SignupToken } from '$lib/types/signup-token.type';
|
||||
import type { UserGroup } from '$lib/types/user-group.type';
|
||||
import type { User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
@@ -76,8 +76,12 @@ export default class UserService extends APIService {
|
||||
return res.data.token;
|
||||
};
|
||||
|
||||
createSignupToken = async (ttl: string | number, usageLimit: number) => {
|
||||
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit });
|
||||
createSignupToken = async (
|
||||
ttl: string | number,
|
||||
usageLimit: number,
|
||||
userGroupIds: string[] = []
|
||||
) => {
|
||||
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit, userGroupIds });
|
||||
return res.data.token;
|
||||
};
|
||||
|
||||
@@ -111,7 +115,7 @@ export default class UserService extends APIService {
|
||||
|
||||
listSignupTokens = async (options?: ListRequestOptions) => {
|
||||
const res = await this.api.get('/signup-tokens', { params: options });
|
||||
return res.data as Paginated<SignupTokenDto>;
|
||||
return res.data as Paginated<SignupToken>;
|
||||
};
|
||||
|
||||
deleteSignupToken = async (tokenId: string) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ export type OidcClient = OidcClientMetaData & {
|
||||
requiresReauthentication: boolean;
|
||||
credentials?: OidcClientCredentials;
|
||||
launchURL?: string;
|
||||
isGroupRestricted: boolean;
|
||||
};
|
||||
|
||||
export type OidcClientWithAllowedUserGroups = OidcClient & {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export interface SignupTokenDto {
|
||||
import type { UserGroup } from './user-group.type';
|
||||
|
||||
export interface SignupToken {
|
||||
id: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
usageLimit: number;
|
||||
usageCount: number;
|
||||
userGroups: UserGroup[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ 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 = {
|
||||
getUrl: () =>
|
||||
getCachedImageUrl(
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { cn } from 'tailwind-variants';
|
||||
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -55,13 +56,18 @@
|
||||
{m.authenticate_with_passkey_to_access_account()}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-10 flex justify-center gap-3">
|
||||
<div class="mt-10 flex justify-center gap-3 w-full max-w-[450px]">
|
||||
{#if $appConfigStore.allowUserSignups === 'open'}
|
||||
<Button variant="secondary" href="/signup">
|
||||
<Button class="w-[50%]" variant="secondary" href="/signup">
|
||||
{m.signup()}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button {isLoading} onclick={authenticate} autofocus={true}>
|
||||
<Button
|
||||
class={cn($appConfigStore.allowUserSignups === 'open' && 'w-[50%]')}
|
||||
{isLoading}
|
||||
onclick={authenticate}
|
||||
autofocus={true}
|
||||
>
|
||||
{error ? m.try_again() : m.authenticate()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
async function updateImages(
|
||||
logoLight: File | undefined,
|
||||
logoDark: File | undefined,
|
||||
logoEmail: File | undefined,
|
||||
defaultProfilePicture: File | null | undefined,
|
||||
backgroundImage: File | undefined,
|
||||
favicon: File | undefined
|
||||
@@ -56,6 +57,10 @@
|
||||
? appConfigService.updateLogo(logoDark, false)
|
||||
: Promise.resolve();
|
||||
|
||||
const emailLogoPromise = logoEmail
|
||||
? appConfigService.updateEmailLogo(logoEmail)
|
||||
: Promise.resolve();
|
||||
|
||||
const defaultProfilePicturePromise =
|
||||
defaultProfilePicture === null
|
||||
? appConfigService.deleteDefaultProfilePicture()
|
||||
@@ -70,6 +75,7 @@
|
||||
await Promise.all([
|
||||
lightLogoPromise,
|
||||
darkLogoPromise,
|
||||
emailLogoPromise,
|
||||
defaultProfilePicturePromise,
|
||||
backgroundImagePromise,
|
||||
faviconPromise
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<script lang="ts">
|
||||
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
|
||||
import SearchableMultiSelect from '$lib/components/form/searchable-multi-select.svelte';
|
||||
import UserGroupInput from '$lib/components/form/user-group-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserGroupService from '$lib/services/user-group-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
import { debounced } from '$lib/utils/debounce-util';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
@@ -21,14 +18,10 @@
|
||||
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const userGroupService = new UserGroupService();
|
||||
|
||||
let userGroups = $state<{ value: string; label: string }[]>([]);
|
||||
let selectedGroups = $state<{ value: string; label: string }[]>([]);
|
||||
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
|
||||
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
|
||||
let allowUserSignups = $state(appConfig.allowUserSignups);
|
||||
let isLoading = $state(false);
|
||||
let isUserSearchLoading = $state(false);
|
||||
|
||||
const signupOptions = {
|
||||
disabled: {
|
||||
@@ -45,42 +38,11 @@
|
||||
}
|
||||
};
|
||||
|
||||
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() {
|
||||
isLoading = true;
|
||||
await callback({
|
||||
allowUserSignups: allowUserSignups,
|
||||
signupDefaultUserGroupIDs: selectedGroups.map((g) => g.value),
|
||||
signupDefaultUserGroupIDs: selectedGroupIds,
|
||||
signupDefaultCustomClaims: customClaims
|
||||
});
|
||||
toast.success(m.user_creation_updated_successfully());
|
||||
@@ -88,12 +50,9 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadSelectedGroups();
|
||||
customClaims = appConfig.signupDefaultCustomClaims || [];
|
||||
allowUserSignups = appConfig.allowUserSignups;
|
||||
});
|
||||
|
||||
onMount(() => loadUserGroups());
|
||||
</script>
|
||||
|
||||
<form onsubmit={preventDefault(onSubmit)}>
|
||||
@@ -152,17 +111,7 @@
|
||||
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||
{m.user_creation_groups_description()}
|
||||
</p>
|
||||
<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
|
||||
/>
|
||||
<UserGroupInput bind:selectedGroupIds />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-0">{m.custom_claims()}</Label>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import {
|
||||
cachedApplicationLogo,
|
||||
cachedBackgroundImage,
|
||||
cachedDefaultProfilePicture
|
||||
cachedDefaultProfilePicture,
|
||||
cachedEmailLogo
|
||||
} from '$lib/utils/cached-image-util';
|
||||
import ApplicationImage from './application-image.svelte';
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
callback: (
|
||||
logoLight: File | undefined,
|
||||
logoDark: File | undefined,
|
||||
logoEmail: File | undefined,
|
||||
defaultProfilePicture: File | null | undefined,
|
||||
backgroundImage: File | undefined,
|
||||
favicon: File | undefined
|
||||
@@ -22,6 +24,7 @@
|
||||
|
||||
let logoLight = $state<File | undefined>();
|
||||
let logoDark = $state<File | undefined>();
|
||||
let logoEmail = $state<File | undefined>();
|
||||
let defaultProfilePicture = $state<File | null | undefined>();
|
||||
let backgroundImage = $state<File | undefined>();
|
||||
let favicon = $state<File | undefined>();
|
||||
@@ -54,6 +57,15 @@
|
||||
imageURL={cachedApplicationLogo.getUrl(false)}
|
||||
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
|
||||
id="default-profile-picture"
|
||||
imageClass="size-24"
|
||||
@@ -75,7 +87,8 @@
|
||||
<Button
|
||||
class="mt-5"
|
||||
usePromiseLoading
|
||||
onclick={() => callback(logoLight, logoDark, defaultProfilePicture, backgroundImage, favicon)}
|
||||
onclick={() =>
|
||||
callback(logoLight, logoDark, logoEmail, defaultProfilePicture, backgroundImage, favicon)}
|
||||
>{m.save()}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,16 @@
|
||||
return success;
|
||||
}
|
||||
|
||||
async function toggleGroupRestriction() {
|
||||
client.isGroupRestricted = !client.isGroupRestricted;
|
||||
await oidcService
|
||||
.updateClient(client.id, client)
|
||||
.then(() => {
|
||||
toast.success(m.user_groups_restriction_updated_successfully());
|
||||
})
|
||||
.catch(axiosErrorToast);
|
||||
}
|
||||
|
||||
async function createClientSecret() {
|
||||
openConfirmDialog({
|
||||
title: m.create_new_client_secret(),
|
||||
@@ -104,7 +114,7 @@
|
||||
await oidcService
|
||||
.updateAllowedUserGroups(client.id, allowedGroups)
|
||||
.then(() => {
|
||||
toast.success(m.allowed_user_groups_updated_successfully());
|
||||
toast.success(m.user_groups_restriction_updated_successfully());
|
||||
})
|
||||
.catch((e) => {
|
||||
axiosErrorToast(e);
|
||||
@@ -120,6 +130,14 @@
|
||||
<title>{m.oidc_client_name({ name: client.name })}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#snippet UnrestrictButton()}
|
||||
<Button
|
||||
onclick={toggleGroupRestriction}
|
||||
variant={client.isGroupRestricted ? 'secondary' : 'default'}
|
||||
>{client.isGroupRestricted ? m.unrestrict() : m.restrict()}</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<div>
|
||||
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</button
|
||||
@@ -193,12 +211,23 @@
|
||||
<CollapsibleCard
|
||||
id="allowed-user-groups"
|
||||
title={m.allowed_user_groups()}
|
||||
description={m.add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups()}
|
||||
button={!client.isGroupRestricted ? UnrestrictButton : undefined}
|
||||
forcedExpanded={client.isGroupRestricted ? undefined : false}
|
||||
description={client.isGroupRestricted
|
||||
? m.allowed_user_groups_description()
|
||||
: m.allowed_user_groups_status_unrestricted_description()}
|
||||
>
|
||||
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
|
||||
</div>
|
||||
{#if client.isGroupRestricted}
|
||||
<UserGroupSelection
|
||||
bind:selectedGroupIds={client.allowedUserGroupIds}
|
||||
selectionDisabled={!client.isGroupRestricted}
|
||||
/>
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<Button onclick={toggleGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
|
||||
|
||||
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsibleCard>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
|
||||
@@ -102,7 +102,8 @@
|
||||
logo: $inputs.logoUrl?.value ? undefined : logo,
|
||||
logoUrl: $inputs.logoUrl?.value,
|
||||
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
|
||||
darkLogoUrl: $inputs.darkLogoUrl?.value
|
||||
darkLogoUrl: $inputs.darkLogoUrl?.value,
|
||||
isGroupRestricted: existingClient?.isGroupRestricted ?? true
|
||||
});
|
||||
|
||||
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
|
||||
|
||||
@@ -64,8 +64,7 @@
|
||||
<DropdownButton.Main disabled={false} onclick={() => (expandAddUser = true)}>
|
||||
{selectedCreateOptions}
|
||||
</DropdownButton.Main>
|
||||
|
||||
<DropdownButton.DropdownTrigger>
|
||||
<DropdownButton.DropdownTrigger aria-label="Create options">
|
||||
<DropdownButton.Trigger class="border-l" />
|
||||
</DropdownButton.DropdownTrigger>
|
||||
</DropdownButton.Root>
|
||||
|
||||
BIN
tests/assets/cloud-logo.png
Normal file
BIN
tests/assets/cloud-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
3
tests/assets/cloud-logo.svg
Normal file
3
tests/assets/cloud-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB |
@@ -66,7 +66,7 @@ export const oidcClients = {
|
||||
|
||||
export const userGroups = {
|
||||
developers: {
|
||||
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
|
||||
id: 'c7ae7c01-28a3-4f3c-9572-1ee734ea8368',
|
||||
friendlyName: 'Developers',
|
||||
name: 'developers'
|
||||
},
|
||||
|
||||
@@ -116,30 +116,49 @@ test('Update email configuration', async ({ page }) => {
|
||||
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
|
||||
});
|
||||
|
||||
test('Update application images', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Expand card' }).nth(4).click();
|
||||
test.describe('Update application images', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Expand card' }).nth(4).click();
|
||||
});
|
||||
|
||||
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('Dark Mode Logo').setInputFiles('assets/nextcloud-logo.png');
|
||||
await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png');
|
||||
await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg');
|
||||
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||
test('should upload images', async ({ page }) => {
|
||||
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('Dark Mode Logo').setInputFiles('assets/cloud-logo.png');
|
||||
await page.getByLabel('Email Logo').setInputFiles('assets/pingvin-share-logo.png');
|
||||
await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png');
|
||||
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(
|
||||
'Images updated successfully. It may take a few minutes to update.'
|
||||
);
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'Images updated successfully. It may take a few minutes to update.'
|
||||
);
|
||||
|
||||
await page.request
|
||||
.get('/api/application-images/favicon')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-images/logo?light=true')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-images/logo?light=false')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-images/background')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
});
|
||||
await page.request
|
||||
.get('/api/application-images/favicon')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-images/logo?light=true')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-images/logo?light=false')
|
||||
.then((res) => expect.soft(res.status()).toBe(200));
|
||||
await page.request
|
||||
.get('/api/application-images/email')
|
||||
.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.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
||||
await page.locator('[role="tab"][data-value="light-logo"]').first().click();
|
||||
await page.setInputFiles('#oidc-client-logo-light', 'assets/nextcloud-logo.png');
|
||||
await page.setInputFiles('#oidc-client-logo-light', 'assets/cloud-logo.png');
|
||||
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
|
||||
await page.setInputFiles('#oidc-client-logo-dark', 'assets/nextcloud-logo.png');
|
||||
await page.setInputFiles('#oidc-client-logo-dark', 'assets/cloud-logo.png');
|
||||
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import test, { expect, type Page } from '@playwright/test';
|
||||
import { signupTokens, users } from '../data';
|
||||
import { signupTokens, userGroups, users } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import passkeyUtil from '../utils/passkey.util';
|
||||
|
||||
async function setSignupMode(page: Page, mode: 'Disabled' | 'Signup with token' | 'Open Signup') {
|
||||
async function setSignupMode(
|
||||
page: Page,
|
||||
mode: 'Disabled' | 'Signup with token' | 'Open Signup',
|
||||
signout = true
|
||||
) {
|
||||
await page.goto('/settings/admin/application-configuration');
|
||||
|
||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||
@@ -15,10 +19,51 @@ async function setSignupMode(page: Page, mode: 'Disabled' | 'Signup with token'
|
||||
'User creation settings updated successfully.'
|
||||
);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/login');
|
||||
if (signout) {
|
||||
await page.context().clearCookies();
|
||||
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.beforeEach(async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
@@ -74,6 +119,9 @@ test.describe('User Signup', () => {
|
||||
|
||||
await page.waitForURL('/signup/add-passkey');
|
||||
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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user