Compare commits

...

5 Commits

Author SHA1 Message Date
Elias Schneider
f61c784988 feat: restrict oidc clients by user groups per default 2025-12-23 13:51:35 +01:00
Jenic Rycr
e358c433f0 feat: allow audit log retention to be controlled by env variable (#1158) 2025-12-23 13:50:00 +01:00
Elias Schneider
08e4ffeb60 feat: minor redesign of auth pages 2025-12-22 21:36:23 +01:00
Elias Schneider
59ca6b26ac feat: add ability define user groups for sign up tokens (#1155) 2025-12-21 18:26:52 +01:00
Melvin Snijders
f5da11b99b feat: add email logo customization (#1150) 2025-12-17 16:20:22 +01:00
55 changed files with 700 additions and 250 deletions

View File

@@ -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.

View File

@@ -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")

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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,
}

View File

@@ -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))

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

View File

@@ -0,0 +1 @@
DROP TABLE signup_tokens_user_groups;

View File

@@ -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
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
DROP TABLE signup_tokens_user_groups;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -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;

View File

@@ -0,0 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients DROP COLUMN is_group_restricted;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -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;

View File

@@ -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."
}

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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">

View 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
/>

View File

@@ -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]'

View File

@@ -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' : ''}"
>

View File

@@ -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()}

View File

@@ -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}

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -28,6 +28,7 @@ export type OidcClient = OidcClientMetaData & {
requiresReauthentication: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
isGroupRestricted: boolean;
};
export type OidcClientWithAllowedUserGroups = OidcClient & {

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

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

View File

@@ -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'
},

View File

@@ -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'
);
});
});

View File

@@ -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();

View File

@@ -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 }) => {