mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-09 22:52:58 +03:00
feat: add support for dark mode oidc client icons (#1039)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -357,6 +358,7 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
|||||||
clientDto := dto.OidcClientMetaDataDto{}
|
clientDto := dto.OidcClientMetaDataDto{}
|
||||||
err = dto.MapStruct(client, &clientDto)
|
err = dto.MapStruct(client, &clientDto)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
clientDto.HasDarkLogo = client.HasDarkLogo()
|
||||||
c.JSON(http.StatusOK, clientDto)
|
c.JSON(http.StatusOK, clientDto)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -419,6 +421,7 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
|||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
clientDto.HasDarkLogo = client.HasDarkLogo()
|
||||||
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
|
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
@@ -539,10 +542,13 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
|||||||
// @Produce image/jpeg
|
// @Produce image/jpeg
|
||||||
// @Produce image/svg+xml
|
// @Produce image/svg+xml
|
||||||
// @Param id path string true "Client ID"
|
// @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"
|
// @Success 200 {file} binary "Logo image"
|
||||||
// @Router /api/oidc/clients/{id}/logo [get]
|
// @Router /api/oidc/clients/{id}/logo [get]
|
||||||
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -561,6 +567,7 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
|||||||
// @Accept multipart/form-data
|
// @Accept multipart/form-data
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
|
// @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"
|
// @Success 204 "No Content"
|
||||||
// @Router /api/oidc/clients/{id}/logo [post]
|
// @Router /api/oidc/clients/{id}/logo [post]
|
||||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||||
@@ -570,13 +577,16 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteClientLogoHandler godoc
|
// deleteClientLogoHandler godoc
|
||||||
@@ -584,16 +594,26 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
|||||||
// @Description Delete the logo for an OIDC client
|
// @Description Delete the logo for an OIDC client
|
||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Param id path string true "Client ID"
|
// @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"
|
// @Success 204 "No Content"
|
||||||
// @Router /api/oidc/clients/{id}/logo [delete]
|
// @Router /api/oidc/clients/{id}/logo [delete]
|
||||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateAllowedUserGroupsHandler godoc
|
// updateAllowedUserGroupsHandler godoc
|
||||||
@@ -624,6 +644,7 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
|||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, oidcClientDto)
|
c.JSON(http.StatusOK, oidcClientDto)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type OidcClientMetaDataDto struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
HasLogo bool `json:"hasLogo"`
|
HasLogo bool `json:"hasLogo"`
|
||||||
|
HasDarkLogo bool `json:"hasDarkLogo"`
|
||||||
LaunchURL *string `json:"launchURL"`
|
LaunchURL *string `json:"launchURL"`
|
||||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||||
}
|
}
|
||||||
@@ -39,7 +40,9 @@ type OidcClientUpdateDto struct {
|
|||||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||||
HasLogo bool `json:"hasLogo"`
|
HasLogo bool `json:"hasLogo"`
|
||||||
|
HasDarkLogo bool `json:"hasDarkLogo"`
|
||||||
LogoURL *string `json:"logoUrl"`
|
LogoURL *string `json:"logoUrl"`
|
||||||
|
DarkLogoURL *string `json:"darkLogoUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
type OidcClientCreateDto struct {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type OidcClient struct {
|
|||||||
CallbackURLs UrlList
|
CallbackURLs UrlList
|
||||||
LogoutCallbackURLs UrlList
|
LogoutCallbackURLs UrlList
|
||||||
ImageType *string
|
ImageType *string
|
||||||
|
DarkImageType *string
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
PkceEnabled bool `filterable:"true"`
|
PkceEnabled bool `filterable:"true"`
|
||||||
RequiresReauthentication bool `filterable:"true"`
|
RequiresReauthentication bool `filterable:"true"`
|
||||||
@@ -68,6 +69,10 @@ func (c OidcClient) HasLogo() bool {
|
|||||||
return c.ImageType != nil && *c.ImageType != ""
|
return c.ImageType != nil && *c.ImageType != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c OidcClient) HasDarkLogo() bool {
|
||||||
|
return c.DarkImageType != nil && *c.DarkImageType != ""
|
||||||
|
}
|
||||||
|
|
||||||
type OidcRefreshToken struct {
|
type OidcRefreshToken struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
|
|||||||
@@ -746,12 +746,19 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
|
|||||||
}
|
}
|
||||||
|
|
||||||
if input.LogoURL != nil {
|
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 {
|
if err != nil {
|
||||||
return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err)
|
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
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.OidcClient{}, err
|
return model.OidcClient{}, err
|
||||||
@@ -778,12 +785,19 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
|
|||||||
}
|
}
|
||||||
|
|
||||||
if input.LogoURL != nil {
|
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 {
|
if err != nil {
|
||||||
return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err)
|
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 {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return model.OidcClient{}, err
|
return model.OidcClient{}, err
|
||||||
}
|
}
|
||||||
@@ -870,7 +884,7 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string) (
|
|||||||
return clientSecret, nil
|
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
|
var client model.OidcClient
|
||||||
err := s.db.
|
err := s.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
@@ -880,23 +894,38 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string) (strin
|
|||||||
return "", "", err
|
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")
|
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
|
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))
|
fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
|
||||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||||
return &common.FileTypeNotSupportedError{}
|
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)
|
err := utils.SaveFile(file, imagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -904,7 +933,7 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil
|
|||||||
|
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
|
|
||||||
err = s.updateClientLogoType(ctx, tx, clientID, fileType)
|
err = s.updateClientLogoType(ctx, tx, clientID, fileType, light)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
@@ -956,6 +985,49 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
|
|||||||
return nil
|
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) {
|
func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -1329,9 +1401,10 @@ func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, us
|
|||||||
|
|
||||||
return &dto.DeviceCodeInfoDto{
|
return &dto.DeviceCodeInfoDto{
|
||||||
Client: dto.OidcClientMetaDataDto{
|
Client: dto.OidcClientMetaDataDto{
|
||||||
ID: deviceAuth.Client.ID,
|
ID: deviceAuth.Client.ID,
|
||||||
Name: deviceAuth.Client.Name,
|
Name: deviceAuth.Client.Name,
|
||||||
HasLogo: deviceAuth.Client.HasLogo(),
|
HasLogo: deviceAuth.Client.HasLogo(),
|
||||||
|
HasDarkLogo: deviceAuth.Client.HasDarkLogo(),
|
||||||
},
|
},
|
||||||
Scope: deviceAuth.Scope,
|
Scope: deviceAuth.Scope,
|
||||||
AuthorizationRequired: !hasAuthorizedClient,
|
AuthorizationRequired: !hasAuthorizedClient,
|
||||||
@@ -1463,10 +1536,11 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
|||||||
}
|
}
|
||||||
dtos[i] = dto.AccessibleOidcClientDto{
|
dtos[i] = dto.AccessibleOidcClientDto{
|
||||||
OidcClientMetaDataDto: dto.OidcClientMetaDataDto{
|
OidcClientMetaDataDto: dto.OidcClientMetaDataDto{
|
||||||
ID: client.ID,
|
ID: client.ID,
|
||||||
Name: client.Name,
|
Name: client.Name,
|
||||||
LaunchURL: client.LaunchURL,
|
LaunchURL: client.LaunchURL,
|
||||||
HasLogo: client.HasLogo(),
|
HasLogo: client.HasLogo(),
|
||||||
|
HasDarkLogo: client.HasDarkLogo(),
|
||||||
},
|
},
|
||||||
LastUsedAt: lastUsedAt,
|
LastUsedAt: lastUsedAt,
|
||||||
}
|
}
|
||||||
@@ -1888,7 +1962,7 @@ func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID str
|
|||||||
return s.IsUserGroupAllowedToAuthorize(user, client), nil
|
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)
|
u, err := url.Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1949,31 +2023,51 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, tx *
|
|||||||
return err
|
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)
|
err = utils.SaveFileStream(io.LimitReader(resp.Body, maxLogoSize+1), imagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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"
|
uploadsDir := common.EnvConfig.UploadPath + "/oidc-client-images"
|
||||||
|
|
||||||
|
var darkSuffix string
|
||||||
|
if !light {
|
||||||
|
darkSuffix = "-dark"
|
||||||
|
}
|
||||||
|
|
||||||
var client model.OidcClient
|
var client model.OidcClient
|
||||||
if err := tx.WithContext(ctx).First(&client, "id = ?", clientID).Error; err != nil {
|
if err := tx.WithContext(ctx).First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if client.ImageType != nil && *client.ImageType != ext {
|
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)
|
_ = 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN dark_image_type;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients ADD COLUMN dark_image_type TEXT;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE oidc_clients DROP COLUMN dark_image_type;
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE oidc_clients ADD COLUMN dark_image_type TEXT;
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -12,11 +12,13 @@
|
|||||||
let {
|
let {
|
||||||
label,
|
label,
|
||||||
accept,
|
accept,
|
||||||
onchange
|
onchange,
|
||||||
|
id = 'file-input'
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
onchange: (file: File | string | null) => void;
|
onchange: (file: File | string | null) => void;
|
||||||
|
id?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let url = $state('');
|
let url = $state('');
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<FileInput
|
<FileInput
|
||||||
id="logo"
|
{id}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
{accept}
|
{accept}
|
||||||
onchange={handleFileChange}
|
onchange={handleFileChange}
|
||||||
@@ -64,9 +66,9 @@
|
|||||||
<LucideChevronDown class="size-4" /></Popover.Trigger
|
<LucideChevronDown class="size-4" /></Popover.Trigger
|
||||||
>
|
>
|
||||||
<Popover.Content class="w-80">
|
<Popover.Content class="w-80">
|
||||||
<Label for="file-url" class="text-xs">URL</Label>
|
<Label for="{id}-url" class="text-xs">URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="file-url"
|
id="{id}-url"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
value={url}
|
value={url}
|
||||||
oninput={(e) => (url = e.currentTarget.value)}
|
oninput={(e) => (url = e.currentTarget.value)}
|
||||||
|
|||||||
@@ -68,25 +68,31 @@ class OidcService extends APIService {
|
|||||||
updateClient = async (id: string, client: OidcClientUpdate) =>
|
updateClient = async (id: string, client: OidcClientUpdate) =>
|
||||||
(await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
|
(await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
|
||||||
|
|
||||||
updateClientLogo = async (client: OidcClient, image: File | null) => {
|
updateClientLogo = async (client: OidcClient, image: File | null, light: boolean = true) => {
|
||||||
if (client.hasLogo && !image) {
|
const hasLogo = light ? client.hasLogo : client.hasDarkLogo;
|
||||||
await this.removeClientLogo(client.id);
|
|
||||||
|
if (hasLogo && !image) {
|
||||||
|
await this.removeClientLogo(client.id, light);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!client.hasLogo && !image) {
|
if (!hasLogo && !image) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', image!);
|
formData.append('file', image!);
|
||||||
|
|
||||||
await this.api.post(`/oidc/clients/${client.id}/logo`, formData);
|
await this.api.post(`/oidc/clients/${client.id}/logo`, formData, {
|
||||||
cachedOidcClientLogo.bustCache(client.id);
|
params: { light }
|
||||||
|
});
|
||||||
|
cachedOidcClientLogo.bustCache(client.id, light);
|
||||||
};
|
};
|
||||||
|
|
||||||
removeClientLogo = async (id: string) => {
|
removeClientLogo = async (id: string, light: boolean = true) => {
|
||||||
await this.api.delete(`/oidc/clients/${id}/logo`);
|
await this.api.delete(`/oidc/clients/${id}/logo`, {
|
||||||
cachedOidcClientLogo.bustCache(id);
|
params: { light }
|
||||||
|
});
|
||||||
|
cachedOidcClientLogo.bustCache(id, light);
|
||||||
};
|
};
|
||||||
|
|
||||||
createClientSecret = async (id: string) =>
|
createClientSecret = async (id: string) =>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type OidcClientMetaData = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
hasLogo: boolean;
|
hasLogo: boolean;
|
||||||
|
hasDarkLogo: boolean;
|
||||||
requiresReauthentication: boolean;
|
requiresReauthentication: boolean;
|
||||||
launchURL?: string;
|
launchURL?: string;
|
||||||
};
|
};
|
||||||
@@ -37,17 +38,20 @@ export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
|
|||||||
allowedUserGroupsCount: number;
|
allowedUserGroupsCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OidcClientUpdate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
export type OidcClientUpdate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo'>;
|
||||||
export type OidcClientCreate = OidcClientUpdate & {
|
export type OidcClientCreate = OidcClientUpdate & {
|
||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
export type OidcClientUpdateWithLogo = OidcClientUpdate & {
|
export type OidcClientUpdateWithLogo = OidcClientUpdate & {
|
||||||
logo: File | null | undefined;
|
logo: File | null | undefined;
|
||||||
|
darkLogo: File | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||||
logo?: File | null;
|
logo?: File | null;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
|
darkLogo?: File | null;
|
||||||
|
darkLogoUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OidcDeviceCodeInfo = {
|
export type OidcDeviceCodeInfo = {
|
||||||
|
|||||||
@@ -9,73 +9,85 @@ type CachableImage = {
|
|||||||
|
|
||||||
export const cachedApplicationLogo: CachableImage = {
|
export const cachedApplicationLogo: CachableImage = {
|
||||||
getUrl: (light = true) => {
|
getUrl: (light = true) => {
|
||||||
let url = '/api/application-images/logo';
|
const url = new URL('/api/application-images/logo', window.location.origin);
|
||||||
if (!light) {
|
if (!light) url.searchParams.set('light', 'false');
|
||||||
url += '?light=false';
|
|
||||||
}
|
|
||||||
return getCachedImageUrl(url);
|
return getCachedImageUrl(url);
|
||||||
},
|
},
|
||||||
bustCache: (light = true) => {
|
bustCache: (light = true) => {
|
||||||
let url = '/api/application-images/logo';
|
const url = new URL('/api/application-images/logo', window.location.origin);
|
||||||
if (!light) {
|
if (!light) url.searchParams.set('light', 'false');
|
||||||
url += '?light=false';
|
|
||||||
}
|
|
||||||
bustImageCache(url);
|
bustImageCache(url);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cachedBackgroundImage: CachableImage = {
|
export const cachedBackgroundImage: CachableImage = {
|
||||||
getUrl: () => getCachedImageUrl('/api/application-images/background'),
|
getUrl: () =>
|
||||||
bustCache: () => bustImageCache('/api/application-images/background')
|
getCachedImageUrl(new URL('/api/application-images/background', window.location.origin)),
|
||||||
|
bustCache: () =>
|
||||||
|
bustImageCache(new URL('/api/application-images/background', window.location.origin))
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cachedProfilePicture: CachableImage = {
|
export const cachedProfilePicture: CachableImage = {
|
||||||
getUrl: (userId: string) => {
|
getUrl: (userId: string) => {
|
||||||
const url = `/api/users/${userId}/profile-picture.png`;
|
const url = new URL(`/api/users/${userId}/profile-picture.png`, window.location.origin);
|
||||||
return getCachedImageUrl(url);
|
return getCachedImageUrl(url);
|
||||||
},
|
},
|
||||||
bustCache: (userId: string) => {
|
bustCache: (userId: string) => {
|
||||||
const url = `/api/users/${userId}/profile-picture.png`;
|
const url = new URL(`/api/users/${userId}/profile-picture.png`, window.location.origin);
|
||||||
bustImageCache(url);
|
bustImageCache(url);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cachedOidcClientLogo: CachableImage = {
|
export const cachedOidcClientLogo: CachableImage = {
|
||||||
getUrl: (clientId: string) => {
|
getUrl: (clientId: string, light = true) => {
|
||||||
const url = `/api/oidc/clients/${clientId}/logo`;
|
const url = new URL(`/api/oidc/clients/${clientId}/logo`, window.location.origin);
|
||||||
|
if (!light) url.searchParams.set('light', 'false');
|
||||||
return getCachedImageUrl(url);
|
return getCachedImageUrl(url);
|
||||||
},
|
},
|
||||||
bustCache: (clientId: string) => {
|
bustCache: (clientId: string, light = true) => {
|
||||||
const url = `/api/oidc/clients/${clientId}/logo`;
|
const url = new URL(`/api/oidc/clients/${clientId}/logo`, window.location.origin);
|
||||||
|
if (!light) url.searchParams.set('light', 'false');
|
||||||
bustImageCache(url);
|
bustImageCache(url);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function getCachedImageUrl(url: string) {
|
function getCachedImageUrl(url: URL) {
|
||||||
const skipCacheUntil = getSkipCacheUntil(url);
|
const baseKey = normalizeUrlForKey(url);
|
||||||
|
const skipCacheUntil = getSkipCacheUntil(baseKey);
|
||||||
const skipCache = skipCacheUntil > Date.now();
|
const skipCache = skipCacheUntil > Date.now();
|
||||||
|
|
||||||
|
const finalUrl = new URL(url.toString());
|
||||||
if (skipCache) {
|
if (skipCache) {
|
||||||
const skipCacheParam = new URLSearchParams();
|
finalUrl.searchParams.set('skip-cache', skipCacheUntil.toString());
|
||||||
skipCacheParam.append('skip-cache', skipCacheUntil.toString());
|
|
||||||
url += '?' + skipCacheParam.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return url.toString();
|
return finalUrl.pathname + (finalUrl.search ? `?${finalUrl.searchParams.toString()}` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function bustImageCache(url: string) {
|
function bustImageCache(url: URL) {
|
||||||
const skipCacheUntil: SkipCacheUntil = JSON.parse(
|
const key = normalizeUrlForKey(url);
|
||||||
localStorage.getItem('skip-cache-until') ?? '{}'
|
const expiresAt = Date.now() + 1000 * 60 * 15;
|
||||||
);
|
|
||||||
skipCacheUntil[hashKey(url)] = Date.now() + 1000 * 60 * 15; // 15 minutes
|
const store: SkipCacheUntil = JSON.parse(localStorage.getItem('skip-cache-until') ?? '{}');
|
||||||
localStorage.setItem('skip-cache-until', JSON.stringify(skipCacheUntil));
|
store[key] = expiresAt;
|
||||||
|
localStorage.setItem('skip-cache-until', JSON.stringify(store));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSkipCacheUntil(url: string) {
|
function getSkipCacheUntil(key: string): number {
|
||||||
const skipCacheUntil: SkipCacheUntil = JSON.parse(
|
const store: SkipCacheUntil = JSON.parse(localStorage.getItem('skip-cache-until') ?? '{}');
|
||||||
localStorage.getItem('skip-cache-until') ?? '{}'
|
return store[key] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes transient params and normalizes query order before hashing
|
||||||
|
function normalizeUrlForKey(url: URL) {
|
||||||
|
const u = new URL(url.toString());
|
||||||
|
u.searchParams.delete('skip-cache');
|
||||||
|
|
||||||
|
const sortedParams = new URLSearchParams(
|
||||||
|
[...u.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||||
);
|
);
|
||||||
return skipCacheUntil[hashKey(url)] ?? 0;
|
const normalized = u.pathname + (sortedParams.toString() ? `?${sortedParams.toString()}` : '');
|
||||||
|
return hashKey(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashKey(key: string): string {
|
function hashKey(key: string): string {
|
||||||
@@ -83,7 +95,7 @@ function hashKey(key: string): string {
|
|||||||
for (let i = 0; i < key.length; i++) {
|
for (let i = 0; i < key.length; i++) {
|
||||||
const char = key.charCodeAt(i);
|
const char = key.charCodeAt(i);
|
||||||
hash = (hash << 5) - hash + char;
|
hash = (hash << 5) - hash + char;
|
||||||
hash = hash & hash;
|
hash |= 0;
|
||||||
}
|
}
|
||||||
return Math.abs(hash).toString(36);
|
return Math.abs(hash).toString(36);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@
|
|||||||
<div
|
<div
|
||||||
class={{
|
class={{
|
||||||
'group relative flex items-center rounded': true,
|
'group relative flex items-center rounded': true,
|
||||||
'bg-[#F1F1F5]': forceColorScheme === 'light',
|
'bg-[#F5F5F5]': forceColorScheme === 'light',
|
||||||
'bg-[#27272A]': forceColorScheme === 'dark',
|
'bg-[#262626]': forceColorScheme === 'dark',
|
||||||
'bg-muted': !forceColorScheme
|
'bg-muted': !forceColorScheme
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -47,7 +47,12 @@
|
|||||||
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
||||||
const imagePromise =
|
const imagePromise =
|
||||||
updatedClient.logo !== undefined
|
updatedClient.logo !== undefined
|
||||||
? oidcService.updateClientLogo(client, updatedClient.logo)
|
? oidcService.updateClientLogo(client, updatedClient.logo, true)
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
const darkImagePromise =
|
||||||
|
updatedClient.darkLogo !== undefined
|
||||||
|
? oidcService.updateClientLogo(client, updatedClient.darkLogo, false)
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
client.isPublic = updatedClient.isPublic;
|
client.isPublic = updatedClient.isPublic;
|
||||||
@@ -56,8 +61,15 @@
|
|||||||
? m.enabled()
|
? m.enabled()
|
||||||
: m.disabled();
|
: m.disabled();
|
||||||
|
|
||||||
await Promise.all([dataPromise, imagePromise])
|
await Promise.all([dataPromise, imagePromise, darkImagePromise])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
// Update the hasLogo and hasDarkLogo flags after successful upload
|
||||||
|
if (updatedClient.logo !== undefined) {
|
||||||
|
client.hasLogo = updatedClient.logo !== null || !!updatedClient.logoUrl;
|
||||||
|
}
|
||||||
|
if (updatedClient.darkLogo !== undefined) {
|
||||||
|
client.hasDarkLogo = updatedClient.darkLogo !== null || !!updatedClient.darkLogoUrl;
|
||||||
|
}
|
||||||
toast.success(m.oidc_client_updated_successfully());
|
toast.success(m.oidc_client_updated_successfully());
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import FormInput from '$lib/components/form/form-input.svelte';
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Tabs from '$lib/components/ui/tabs';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import type {
|
import type {
|
||||||
OidcClient,
|
OidcClient,
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
import { cn } from '$lib/utils/style';
|
import { cn } from '$lib/utils/style';
|
||||||
import { callbackUrlSchema, emptyToUndefined, optionalUrl } from '$lib/utils/zod-util';
|
import { callbackUrlSchema, emptyToUndefined, optionalUrl } from '$lib/utils/zod-util';
|
||||||
import { LucideChevronDown } from '@lucide/svelte';
|
import { LucideChevronDown, LucideMoon, LucideSun } from '@lucide/svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import FederatedIdentitiesInput from './federated-identities-input.svelte';
|
import FederatedIdentitiesInput from './federated-identities-input.svelte';
|
||||||
@@ -32,9 +33,13 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let showAdvancedOptions = $state(false);
|
let showAdvancedOptions = $state(false);
|
||||||
let logo = $state<File | null | undefined>();
|
let logo = $state<File | null | undefined>();
|
||||||
|
let darkLogo = $state<File | null | undefined>();
|
||||||
let logoDataURL: string | null = $state(
|
let logoDataURL: string | null = $state(
|
||||||
existingClient?.hasLogo ? cachedOidcClientLogo.getUrl(existingClient!.id) : null
|
existingClient?.hasLogo ? cachedOidcClientLogo.getUrl(existingClient!.id) : null
|
||||||
);
|
);
|
||||||
|
let darkLogoDataURL: string | null = $state(
|
||||||
|
existingClient?.hasDarkLogo ? cachedOidcClientLogo.getUrl(existingClient!.id, false) : null
|
||||||
|
);
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
id: '',
|
id: '',
|
||||||
@@ -48,7 +53,8 @@
|
|||||||
credentials: {
|
credentials: {
|
||||||
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
||||||
},
|
},
|
||||||
logoUrl: ''
|
logoUrl: '',
|
||||||
|
darkLogoUrl: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -70,6 +76,7 @@
|
|||||||
requiresReauthentication: z.boolean(),
|
requiresReauthentication: z.boolean(),
|
||||||
launchURL: optionalUrl,
|
launchURL: optionalUrl,
|
||||||
logoUrl: optionalUrl,
|
logoUrl: optionalUrl,
|
||||||
|
darkLogoUrl: optionalUrl,
|
||||||
credentials: z.object({
|
credentials: z.object({
|
||||||
federatedIdentities: z.array(
|
federatedIdentities: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -92,37 +99,63 @@
|
|||||||
|
|
||||||
const success = await callback({
|
const success = await callback({
|
||||||
...data,
|
...data,
|
||||||
logo: $inputs.logoUrl?.value ? null : logo,
|
logo: $inputs.logoUrl?.value ? undefined : logo,
|
||||||
logoUrl: $inputs.logoUrl?.value
|
logoUrl: $inputs.logoUrl?.value,
|
||||||
|
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
|
||||||
|
darkLogoUrl: $inputs.darkLogoUrl?.value
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
|
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
|
||||||
if (success && existingClient && hasLogo) {
|
const hasDarkLogo = darkLogo != null || !!$inputs.darkLogoUrl?.value;
|
||||||
logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
|
if (success && existingClient) {
|
||||||
|
if (hasLogo) {
|
||||||
|
logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
|
||||||
|
}
|
||||||
|
if (hasDarkLogo) {
|
||||||
|
darkLogoDataURL = cachedOidcClientLogo.getUrl(existingClient.id, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success && !existingClient) form.reset();
|
if (success && !existingClient) form.reset();
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLogoChange(input: File | string | null) {
|
function onLogoChange(input: File | string | null, light: boolean = true) {
|
||||||
if (input == null) return;
|
if (input == null) return;
|
||||||
|
|
||||||
|
const logoUrlInput = light ? $inputs.logoUrl : $inputs.darkLogoUrl;
|
||||||
|
|
||||||
if (typeof input === 'string') {
|
if (typeof input === 'string') {
|
||||||
logo = null;
|
if (light) {
|
||||||
logoDataURL = input || null;
|
logo = null;
|
||||||
$inputs.logoUrl!.value = input;
|
logoDataURL = input || null;
|
||||||
|
} else {
|
||||||
|
darkLogo = null;
|
||||||
|
darkLogoDataURL = input || null;
|
||||||
|
}
|
||||||
|
logoUrlInput!.value = input;
|
||||||
} else {
|
} else {
|
||||||
logo = input;
|
if (light) {
|
||||||
$inputs.logoUrl && ($inputs.logoUrl.value = '');
|
logo = input;
|
||||||
logoDataURL = URL.createObjectURL(input);
|
logoDataURL = URL.createObjectURL(input);
|
||||||
|
} else {
|
||||||
|
darkLogo = input;
|
||||||
|
darkLogoDataURL = URL.createObjectURL(input);
|
||||||
|
}
|
||||||
|
logoUrlInput && (logoUrlInput.value = '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetLogo() {
|
function resetLogo(light: boolean = true) {
|
||||||
logo = null;
|
if (light) {
|
||||||
logoDataURL = null;
|
logo = null;
|
||||||
$inputs.logoUrl && ($inputs.logoUrl.value = '');
|
logoDataURL = null;
|
||||||
|
$inputs.logoUrl && ($inputs.logoUrl.value = '');
|
||||||
|
} else {
|
||||||
|
darkLogo = null;
|
||||||
|
darkLogoDataURL = null;
|
||||||
|
$inputs.darkLogoUrl && ($inputs.darkLogoUrl.value = '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
|
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
|
||||||
@@ -182,13 +215,49 @@
|
|||||||
bind:checked={$inputs.requiresReauthentication.value}
|
bind:checked={$inputs.requiresReauthentication.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-7">
|
<div class="mt-7 w-full md:w-1/2">
|
||||||
<OidcClientImageInput
|
<Tabs.Root value="light-logo">
|
||||||
{logoDataURL}
|
<Tabs.Content value="light-logo">
|
||||||
{resetLogo}
|
<OidcClientImageInput
|
||||||
clientName={$inputs.name.value}
|
{logoDataURL}
|
||||||
{onLogoChange}
|
resetLogo={() => resetLogo(true)}
|
||||||
/>
|
clientName={$inputs.name.value}
|
||||||
|
light={true}
|
||||||
|
onLogoChange={(input) => onLogoChange(input, true)}
|
||||||
|
>
|
||||||
|
{#snippet tabTriggers()}
|
||||||
|
<Tabs.List class="grid h-9 w-full grid-cols-2">
|
||||||
|
<Tabs.Trigger value="light-logo" class="px-3">
|
||||||
|
<LucideSun class="size-4" />
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="dark-logo" class="px-3">
|
||||||
|
<LucideMoon class="size-4" />
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
{/snippet}
|
||||||
|
</OidcClientImageInput>
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="dark-logo">
|
||||||
|
<OidcClientImageInput
|
||||||
|
light={false}
|
||||||
|
logoDataURL={darkLogoDataURL}
|
||||||
|
resetLogo={() => resetLogo(false)}
|
||||||
|
clientName={$inputs.name.value}
|
||||||
|
onLogoChange={(input) => onLogoChange(input, false)}
|
||||||
|
>
|
||||||
|
{#snippet tabTriggers()}
|
||||||
|
<Tabs.List class="grid h-9 w-full grid-cols-2">
|
||||||
|
<Tabs.Trigger value="light-logo" class="px-3">
|
||||||
|
<LucideSun class="size-4" />
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="dark-logo" class="px-3">
|
||||||
|
<LucideMoon class="size-4" />
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
{/snippet}
|
||||||
|
</OidcClientImageInput>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showAdvancedOptions}
|
{#if showAdvancedOptions}
|
||||||
|
|||||||
@@ -5,40 +5,53 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import { LucideX } from '@lucide/svelte';
|
import { LucideX } from '@lucide/svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
logoDataURL,
|
logoDataURL,
|
||||||
clientName,
|
clientName,
|
||||||
resetLogo,
|
resetLogo,
|
||||||
onLogoChange
|
onLogoChange,
|
||||||
|
light,
|
||||||
|
tabTriggers
|
||||||
}: {
|
}: {
|
||||||
logoDataURL: string | null;
|
logoDataURL: string | null;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
resetLogo: () => void;
|
resetLogo: () => void;
|
||||||
onLogoChange: (file: File | string | null) => void;
|
onLogoChange: (file: File | string | null) => void;
|
||||||
|
tabTriggers?: Snippet;
|
||||||
|
light: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let id = `oidc-client-logo-${light ? 'light' : 'dark'}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Label for="logo">{m.logo()}</Label>
|
<Label for={id}>{m.logo()}</Label>
|
||||||
<div class="flex items-end gap-4">
|
<div class="flex h-24 items-end gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#if tabTriggers}
|
||||||
|
{@render tabTriggers()}
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UrlFileInput {id} label={m.upload_logo()} accept="image/*" onchange={onLogoChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{#if logoDataURL}
|
{#if logoDataURL}
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
<ImageBox class="size-24" src={logoDataURL} alt={m.name_logo({ name: clientName })} />
|
<ImageBox
|
||||||
|
class="size-24 {light ? 'bg-[#F5F5F5]' : 'bg-[#262626]'}"
|
||||||
|
src={logoDataURL}
|
||||||
|
alt={m.name_logo({ name: clientName })}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
|
||||||
size="icon"
|
size="icon"
|
||||||
onclick={resetLogo}
|
onclick={resetLogo}
|
||||||
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
|
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md "
|
||||||
>
|
>
|
||||||
<LucideX class="size-3" />
|
<LucideX class="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<UrlFileInput label={m.upload_logo()} accept="image/*" onchange={onLogoChange} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
const oidcService = new OIDCService();
|
const oidcService = new OIDCService();
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
return tableRef?.refresh();
|
return tableRef?.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLightMode = $derived(mode.current === 'light');
|
||||||
|
|
||||||
const booleanFilterValues = [
|
const booleanFilterValues = [
|
||||||
{ label: m.enabled(), value: true },
|
{ label: m.enabled(), value: true },
|
||||||
{ label: m.disabled(), value: false }
|
{ label: m.disabled(), value: false }
|
||||||
@@ -103,7 +106,7 @@
|
|||||||
{#if item.hasLogo}
|
{#if item.hasLogo}
|
||||||
<ImageBox
|
<ImageBox
|
||||||
class="size-12 rounded-lg"
|
class="size-12 rounded-lg"
|
||||||
src={cachedOidcClientLogo.getUrl(item.id)}
|
src={cachedOidcClientLogo.getUrl(item.id, isLightMode)}
|
||||||
alt={m.name_logo({ name: item.name })}
|
alt={m.name_logo({ name: item.name })}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<ImageBox
|
<ImageBox
|
||||||
class="size-14"
|
class="size-14"
|
||||||
src={client.hasLogo
|
src={client.hasLogo
|
||||||
? cachedOidcClientLogo.getUrl(client.id)
|
? cachedOidcClientLogo.getUrl(client.id, isLightMode)
|
||||||
: cachedApplicationLogo.getUrl(isLightMode)}
|
: cachedApplicationLogo.getUrl(isLightMode)}
|
||||||
alt={m.name_logo({ name: client.name })}
|
alt={m.name_logo({ name: client.name })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ test.describe('Create OIDC client', () => {
|
|||||||
await page.getByRole('button', { name: 'Add another' }).click();
|
await page.getByRole('button', { name: 'Add another' }).click();
|
||||||
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl);
|
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl);
|
||||||
|
|
||||||
await page.getByLabel('logo').setInputFiles('assets/pingvin-share-logo.png');
|
await page.locator('[role="tab"][data-value="light-logo"]').first().click();
|
||||||
|
await page.setInputFiles('#oidc-client-logo-light', 'assets/pingvin-share-logo.png');
|
||||||
|
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
|
||||||
|
await page.setInputFiles('#oidc-client-logo-dark', 'assets/pingvin-share-logo.png');
|
||||||
|
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
|
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
|
||||||
@@ -46,7 +49,7 @@ test.describe('Create OIDC client', () => {
|
|||||||
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
|
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
|
||||||
await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
|
await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
|
||||||
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl);
|
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl);
|
||||||
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible();
|
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` }).first()).toBeVisible();
|
||||||
|
|
||||||
const res = await page.request.get(`/api/oidc/clients/${resolvedClientId}/logo`);
|
const res = await page.request.get(`/api/oidc/clients/${resolvedClientId}/logo`);
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
@@ -67,14 +70,17 @@ test('Edit OIDC client', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByLabel('Name').fill('Nextcloud updated');
|
await page.getByLabel('Name').fill('Nextcloud updated');
|
||||||
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
||||||
await page.getByLabel('logo').setInputFiles('assets/nextcloud-logo.png');
|
await page.locator('[role="tab"][data-value="light-logo"]').first().click();
|
||||||
|
await page.setInputFiles('#oidc-client-logo-light', 'assets/nextcloud-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.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
'OIDC client updated successfully'
|
'OIDC client updated successfully'
|
||||||
);
|
);
|
||||||
await expect(page.getByRole('img', { name: 'Nextcloud updated logo' })).toBeVisible();
|
await expect(page.getByRole('img', { name: 'Nextcloud updated logo' }).first()).toBeVisible();
|
||||||
await page.request
|
await page.request
|
||||||
.get(`/api/oidc/clients/${oidcClient.id}/logo`)
|
.get(`/api/oidc/clients/${oidcClient.id}/logo`)
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
|
|||||||
Reference in New Issue
Block a user