diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 7445c0b8..cbe46141 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http" "net/url" + "strconv" "strings" "time" @@ -357,6 +358,7 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) { clientDto := dto.OidcClientMetaDataDto{} err = dto.MapStruct(client, &clientDto) if err == nil { + clientDto.HasDarkLogo = client.HasDarkLogo() c.JSON(http.StatusOK, clientDto) return } @@ -419,6 +421,7 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) { _ = c.Error(err) return } + clientDto.HasDarkLogo = client.HasDarkLogo() clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID) if err != nil { _ = c.Error(err) @@ -539,10 +542,13 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) { // @Produce image/jpeg // @Produce image/svg+xml // @Param id path string true "Client ID" +// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" // @Success 200 {file} binary "Logo image" // @Router /api/oidc/clients/{id}/logo [get] func (oc *OidcController) getClientLogoHandler(c *gin.Context) { - imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id")) + lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) + + imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo) if err != nil { _ = c.Error(err) return @@ -561,6 +567,7 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) { // @Accept multipart/form-data // @Param id path string true "Client ID" // @Param file formData file true "Logo image file (PNG, JPG, or SVG)" +// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" // @Success 204 "No Content" // @Router /api/oidc/clients/{id}/logo [post] func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { @@ -570,13 +577,16 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { return } - err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file) + lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) + + err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file, lightLogo) if err != nil { _ = c.Error(err) return } c.Status(http.StatusNoContent) + } // deleteClientLogoHandler godoc @@ -584,16 +594,26 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { // @Description Delete the logo for an OIDC client // @Tags OIDC // @Param id path string true "Client ID" +// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" // @Success 204 "No Content" // @Router /api/oidc/clients/{id}/logo [delete] func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { - err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id")) + var err error + + lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) + if lightLogo { + err = oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id")) + } else { + err = oc.oidcService.DeleteClientDarkLogo(c.Request.Context(), c.Param("id")) + } + if err != nil { _ = c.Error(err) return } c.Status(http.StatusNoContent) + } // updateAllowedUserGroupsHandler godoc @@ -624,6 +644,7 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) { _ = c.Error(err) return } + oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo() c.JSON(http.StatusOK, oidcClientDto) } diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index 110cc585..9f3239de 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -6,6 +6,7 @@ type OidcClientMetaDataDto struct { ID string `json:"id"` Name string `json:"name"` HasLogo bool `json:"hasLogo"` + HasDarkLogo bool `json:"hasDarkLogo"` LaunchURL *string `json:"launchURL"` RequiresReauthentication bool `json:"requiresReauthentication"` } @@ -39,7 +40,9 @@ type OidcClientUpdateDto struct { Credentials OidcClientCredentialsDto `json:"credentials"` LaunchURL *string `json:"launchURL" binding:"omitempty,url"` HasLogo bool `json:"hasLogo"` + HasDarkLogo bool `json:"hasDarkLogo"` LogoURL *string `json:"logoUrl"` + DarkLogoURL *string `json:"darkLogoUrl"` } type OidcClientCreateDto struct { diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 701d6e7a..e063ddb1 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -52,6 +52,7 @@ type OidcClient struct { CallbackURLs UrlList LogoutCallbackURLs UrlList ImageType *string + DarkImageType *string IsPublic bool PkceEnabled bool `filterable:"true"` RequiresReauthentication bool `filterable:"true"` @@ -68,6 +69,10 @@ func (c OidcClient) HasLogo() bool { return c.ImageType != nil && *c.ImageType != "" } +func (c OidcClient) HasDarkLogo() bool { + return c.DarkImageType != nil && *c.DarkImageType != "" +} + type OidcRefreshToken struct { Base diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index a224a214..977b1fea 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -746,12 +746,19 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea } if input.LogoURL != nil { - err = s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL) + err = s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL, true) if err != nil { return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err) } } + if input.DarkLogoURL != nil { + err = s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.DarkLogoURL, false) + if err != nil { + return model.OidcClient{}, fmt.Errorf("failed to download dark logo: %w", err) + } + } + err = tx.Commit().Error if err != nil { return model.OidcClient{}, err @@ -778,12 +785,19 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d } if input.LogoURL != nil { - err := s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL) + err := s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL, true) if err != nil { return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err) } } + if input.DarkLogoURL != nil { + err := s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.DarkLogoURL, false) + if err != nil { + return model.OidcClient{}, fmt.Errorf("failed to download dark logo: %w", err) + } + } + if err := tx.Commit().Error; err != nil { return model.OidcClient{}, err } @@ -870,7 +884,7 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string) ( return clientSecret, nil } -func (s *OidcService) GetClientLogo(ctx context.Context, clientID string) (string, string, error) { +func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light bool) (string, string, error) { var client model.OidcClient err := s.db. WithContext(ctx). @@ -880,23 +894,38 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string) (strin return "", "", err } - if client.ImageType == nil { + var imagePath, mimeType string + + switch { + case !light && client.DarkImageType != nil: + // Dark logo if requested and exists + imagePath = common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "-dark." + *client.DarkImageType + mimeType = utils.GetImageMimeType(*client.DarkImageType) + + case client.ImageType != nil: + // Light logo if requested or no dark logo is available + imagePath = common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + *client.ImageType + mimeType = utils.GetImageMimeType(*client.ImageType) + + default: return "", "", errors.New("image not found") } - imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + *client.ImageType - mimeType := utils.GetImageMimeType(*client.ImageType) - return imagePath, mimeType, nil } -func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader) error { +func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader, light bool) error { fileType := strings.ToLower(utils.GetFileExtension(file.Filename)) if mimeType := utils.GetImageMimeType(fileType); mimeType == "" { return &common.FileTypeNotSupportedError{} } - imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + clientID + "." + fileType + var darkSuffix string + if !light { + darkSuffix = "-dark" + } + + imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + clientID + darkSuffix + "." + fileType err := utils.SaveFile(file, imagePath) if err != nil { return err @@ -904,7 +933,7 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil tx := s.db.Begin() - err = s.updateClientLogoType(ctx, tx, clientID, fileType) + err = s.updateClientLogoType(ctx, tx, clientID, fileType, light) if err != nil { tx.Rollback() return err @@ -956,6 +985,49 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err return nil } +func (s *OidcService) DeleteClientDarkLogo(ctx context.Context, clientID string) error { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + var client model.OidcClient + err := tx. + WithContext(ctx). + First(&client, "id = ?", clientID). + Error + if err != nil { + return err + } + + if client.DarkImageType == nil { + return errors.New("image not found") + } + + oldImageType := *client.DarkImageType + client.DarkImageType = nil + + err = tx. + WithContext(ctx). + Save(&client). + Error + if err != nil { + return err + } + + imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "-dark." + oldImageType + if err := os.Remove(imagePath); err != nil { + return err + } + + err = tx.Commit().Error + if err != nil { + return err + } + + return nil +} + func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) { tx := s.db.Begin() defer func() { @@ -1329,9 +1401,10 @@ func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, us return &dto.DeviceCodeInfoDto{ Client: dto.OidcClientMetaDataDto{ - ID: deviceAuth.Client.ID, - Name: deviceAuth.Client.Name, - HasLogo: deviceAuth.Client.HasLogo(), + ID: deviceAuth.Client.ID, + Name: deviceAuth.Client.Name, + HasLogo: deviceAuth.Client.HasLogo(), + HasDarkLogo: deviceAuth.Client.HasDarkLogo(), }, Scope: deviceAuth.Scope, AuthorizationRequired: !hasAuthorizedClient, @@ -1463,10 +1536,11 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri } dtos[i] = dto.AccessibleOidcClientDto{ OidcClientMetaDataDto: dto.OidcClientMetaDataDto{ - ID: client.ID, - Name: client.Name, - LaunchURL: client.LaunchURL, - HasLogo: client.HasLogo(), + ID: client.ID, + Name: client.Name, + LaunchURL: client.LaunchURL, + HasLogo: client.HasLogo(), + HasDarkLogo: client.HasDarkLogo(), }, LastUsedAt: lastUsedAt, } @@ -1888,7 +1962,7 @@ func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID str return s.IsUserGroupAllowedToAuthorize(user, client), nil } -func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, tx *gorm.DB, clientID string, raw string) error { +func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, tx *gorm.DB, clientID string, raw string, light bool) error { u, err := url.Parse(raw) if err != nil { return err @@ -1949,31 +2023,51 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, tx * return err } - imagePath := filepath.Join(folderPath, clientID+"."+ext) + var darkSuffix string + if !light { + darkSuffix = "-dark" + } + + imagePath := filepath.Join(folderPath, clientID+darkSuffix+"."+ext) err = utils.SaveFileStream(io.LimitReader(resp.Body, maxLogoSize+1), imagePath) if err != nil { return err } - if err := s.updateClientLogoType(ctx, tx, clientID, ext); err != nil { + if err := s.updateClientLogoType(ctx, tx, clientID, ext, light); err != nil { return err } return nil } -func (s *OidcService) updateClientLogoType(ctx context.Context, tx *gorm.DB, clientID, ext string) error { +func (s *OidcService) updateClientLogoType(ctx context.Context, tx *gorm.DB, clientID, ext string, light bool) error { uploadsDir := common.EnvConfig.UploadPath + "/oidc-client-images" + var darkSuffix string + if !light { + darkSuffix = "-dark" + } + var client model.OidcClient if err := tx.WithContext(ctx).First(&client, "id = ?", clientID).Error; err != nil { return err } if client.ImageType != nil && *client.ImageType != ext { - old := fmt.Sprintf("%s/%s.%s", uploadsDir, client.ID, *client.ImageType) + old := fmt.Sprintf("%s/%s%s.%s", uploadsDir, client.ID, darkSuffix, *client.ImageType) _ = os.Remove(old) } - client.ImageType = &ext - return tx.WithContext(ctx).Save(&client).Error + var column string + if light { + column = "image_type" + } else { + column = "dark_image_type" + } + + return tx.WithContext(ctx). + Model(&model.OidcClient{}). + Where("id = ?", clientID). + Update(column, ext). + Error } diff --git a/backend/resources/migrations/postgres/20251018000000_add_dark_logo.down.sql b/backend/resources/migrations/postgres/20251018000000_add_dark_logo.down.sql new file mode 100644 index 00000000..76e99a76 --- /dev/null +++ b/backend/resources/migrations/postgres/20251018000000_add_dark_logo.down.sql @@ -0,0 +1 @@ +ALTER TABLE oidc_clients DROP COLUMN dark_image_type; diff --git a/backend/resources/migrations/postgres/20251018000000_add_dark_logo.up.sql b/backend/resources/migrations/postgres/20251018000000_add_dark_logo.up.sql new file mode 100644 index 00000000..1b67e37a --- /dev/null +++ b/backend/resources/migrations/postgres/20251018000000_add_dark_logo.up.sql @@ -0,0 +1 @@ +ALTER TABLE oidc_clients ADD COLUMN dark_image_type TEXT; diff --git a/backend/resources/migrations/sqlite/20251018000000_add_dark_logo.down.sql b/backend/resources/migrations/sqlite/20251018000000_add_dark_logo.down.sql new file mode 100644 index 00000000..07b8ef59 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251018000000_add_dark_logo.down.sql @@ -0,0 +1,5 @@ +PRAGMA foreign_keys=OFF; +BEGIN; +ALTER TABLE oidc_clients DROP COLUMN dark_image_type; +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20251018000000_add_dark_logo.up.sql b/backend/resources/migrations/sqlite/20251018000000_add_dark_logo.up.sql new file mode 100644 index 00000000..1bc15456 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251018000000_add_dark_logo.up.sql @@ -0,0 +1,5 @@ +PRAGMA foreign_keys=OFF; +BEGIN; +ALTER TABLE oidc_clients ADD COLUMN dark_image_type TEXT; +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/frontend/src/lib/components/form/url-file-input.svelte b/frontend/src/lib/components/form/url-file-input.svelte index 05aed37b..9d625361 100644 --- a/frontend/src/lib/components/form/url-file-input.svelte +++ b/frontend/src/lib/components/form/url-file-input.svelte @@ -12,11 +12,13 @@ let { label, accept, - onchange + onchange, + id = 'file-input' }: { label: string; accept?: string; onchange: (file: File | string | null) => void; + id?: string; } = $props(); let url = $state(''); @@ -47,7 +49,7 @@