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 @@
-
+
(url = e.currentTarget.value)}
diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts
index 7918155b..2563f2ee 100644
--- a/frontend/src/lib/services/oidc-service.ts
+++ b/frontend/src/lib/services/oidc-service.ts
@@ -68,25 +68,31 @@ class OidcService extends APIService {
updateClient = async (id: string, client: OidcClientUpdate) =>
(await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
- updateClientLogo = async (client: OidcClient, image: File | null) => {
- if (client.hasLogo && !image) {
- await this.removeClientLogo(client.id);
+ updateClientLogo = async (client: OidcClient, image: File | null, light: boolean = true) => {
+ const hasLogo = light ? client.hasLogo : client.hasDarkLogo;
+
+ if (hasLogo && !image) {
+ await this.removeClientLogo(client.id, light);
return;
}
- if (!client.hasLogo && !image) {
+ if (!hasLogo && !image) {
return;
}
const formData = new FormData();
formData.append('file', image!);
- await this.api.post(`/oidc/clients/${client.id}/logo`, formData);
- cachedOidcClientLogo.bustCache(client.id);
+ await this.api.post(`/oidc/clients/${client.id}/logo`, formData, {
+ params: { light }
+ });
+ cachedOidcClientLogo.bustCache(client.id, light);
};
- removeClientLogo = async (id: string) => {
- await this.api.delete(`/oidc/clients/${id}/logo`);
- cachedOidcClientLogo.bustCache(id);
+ removeClientLogo = async (id: string, light: boolean = true) => {
+ await this.api.delete(`/oidc/clients/${id}/logo`, {
+ params: { light }
+ });
+ cachedOidcClientLogo.bustCache(id, light);
};
createClientSecret = async (id: string) =>
diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts
index 18087d88..9c926fbb 100644
--- a/frontend/src/lib/types/oidc.type.ts
+++ b/frontend/src/lib/types/oidc.type.ts
@@ -4,6 +4,7 @@ export type OidcClientMetaData = {
id: string;
name: string;
hasLogo: boolean;
+ hasDarkLogo: boolean;
requiresReauthentication: boolean;
launchURL?: string;
};
@@ -37,17 +38,20 @@ export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
allowedUserGroupsCount: number;
};
-export type OidcClientUpdate = Omit;
+export type OidcClientUpdate = Omit;
export type OidcClientCreate = OidcClientUpdate & {
id?: string;
};
export type OidcClientUpdateWithLogo = OidcClientUpdate & {
logo: File | null | undefined;
+ darkLogo: File | null | undefined;
};
export type OidcClientCreateWithLogo = OidcClientCreate & {
logo?: File | null;
logoUrl?: string;
+ darkLogo?: File | null;
+ darkLogoUrl?: string;
};
export type OidcDeviceCodeInfo = {
diff --git a/frontend/src/lib/utils/cached-image-util.ts b/frontend/src/lib/utils/cached-image-util.ts
index b151f974..05c69a91 100644
--- a/frontend/src/lib/utils/cached-image-util.ts
+++ b/frontend/src/lib/utils/cached-image-util.ts
@@ -9,73 +9,85 @@ type CachableImage = {
export const cachedApplicationLogo: CachableImage = {
getUrl: (light = true) => {
- let url = '/api/application-images/logo';
- if (!light) {
- url += '?light=false';
- }
+ const url = new URL('/api/application-images/logo', window.location.origin);
+ if (!light) url.searchParams.set('light', 'false');
return getCachedImageUrl(url);
},
bustCache: (light = true) => {
- let url = '/api/application-images/logo';
- if (!light) {
- url += '?light=false';
- }
+ const url = new URL('/api/application-images/logo', window.location.origin);
+ if (!light) url.searchParams.set('light', 'false');
bustImageCache(url);
}
};
export const cachedBackgroundImage: CachableImage = {
- getUrl: () => getCachedImageUrl('/api/application-images/background'),
- bustCache: () => bustImageCache('/api/application-images/background')
+ getUrl: () =>
+ 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 = {
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);
},
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);
}
};
export const cachedOidcClientLogo: CachableImage = {
- getUrl: (clientId: string) => {
- const url = `/api/oidc/clients/${clientId}/logo`;
+ getUrl: (clientId: string, light = true) => {
+ const url = new URL(`/api/oidc/clients/${clientId}/logo`, window.location.origin);
+ if (!light) url.searchParams.set('light', 'false');
return getCachedImageUrl(url);
},
- bustCache: (clientId: string) => {
- const url = `/api/oidc/clients/${clientId}/logo`;
+ bustCache: (clientId: string, light = true) => {
+ const url = new URL(`/api/oidc/clients/${clientId}/logo`, window.location.origin);
+ if (!light) url.searchParams.set('light', 'false');
bustImageCache(url);
}
};
-function getCachedImageUrl(url: string) {
- const skipCacheUntil = getSkipCacheUntil(url);
+function getCachedImageUrl(url: URL) {
+ const baseKey = normalizeUrlForKey(url);
+ const skipCacheUntil = getSkipCacheUntil(baseKey);
const skipCache = skipCacheUntil > Date.now();
+
+ const finalUrl = new URL(url.toString());
if (skipCache) {
- const skipCacheParam = new URLSearchParams();
- skipCacheParam.append('skip-cache', skipCacheUntil.toString());
- url += '?' + skipCacheParam.toString();
+ finalUrl.searchParams.set('skip-cache', skipCacheUntil.toString());
}
- return url.toString();
+ return finalUrl.pathname + (finalUrl.search ? `?${finalUrl.searchParams.toString()}` : '');
}
-function bustImageCache(url: string) {
- const skipCacheUntil: SkipCacheUntil = JSON.parse(
- localStorage.getItem('skip-cache-until') ?? '{}'
- );
- skipCacheUntil[hashKey(url)] = Date.now() + 1000 * 60 * 15; // 15 minutes
- localStorage.setItem('skip-cache-until', JSON.stringify(skipCacheUntil));
+function bustImageCache(url: URL) {
+ const key = normalizeUrlForKey(url);
+ const expiresAt = Date.now() + 1000 * 60 * 15;
+
+ const store: SkipCacheUntil = JSON.parse(localStorage.getItem('skip-cache-until') ?? '{}');
+ store[key] = expiresAt;
+ localStorage.setItem('skip-cache-until', JSON.stringify(store));
}
-function getSkipCacheUntil(url: string) {
- const skipCacheUntil: SkipCacheUntil = JSON.parse(
- localStorage.getItem('skip-cache-until') ?? '{}'
+function getSkipCacheUntil(key: string): number {
+ const store: SkipCacheUntil = JSON.parse(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 {
@@ -83,7 +95,7 @@ function hashKey(key: string): string {
for (let i = 0; i < key.length; i++) {
const char = key.charCodeAt(i);
hash = (hash << 5) - hash + char;
- hash = hash & hash;
+ hash |= 0;
}
return Math.abs(hash).toString(36);
}
diff --git a/frontend/src/routes/settings/admin/application-configuration/application-image.svelte b/frontend/src/routes/settings/admin/application-configuration/application-image.svelte
index a7b050ef..86c0e1ed 100644
--- a/frontend/src/routes/settings/admin/application-configuration/application-image.svelte
+++ b/frontend/src/routes/settings/admin/application-configuration/application-image.svelte
@@ -41,8 +41,8 @@
diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte
index 737133d2..a90bafab 100644
--- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte
+++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte
@@ -47,7 +47,12 @@
const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise =
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();
client.isPublic = updatedClient.isPublic;
@@ -56,8 +61,15 @@
? m.enabled()
: m.disabled();
- await Promise.all([dataPromise, imagePromise])
+ await Promise.all([dataPromise, imagePromise, darkImagePromise])
.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());
})
.catch((e) => {
diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte
index e5c2dd69..904e1314 100644
--- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte
+++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte
@@ -2,6 +2,7 @@
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
+ import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
import type {
OidcClient,
@@ -13,7 +14,7 @@
import { createForm } from '$lib/utils/form-util';
import { cn } from '$lib/utils/style';
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 { z } from 'zod/v4';
import FederatedIdentitiesInput from './federated-identities-input.svelte';
@@ -32,9 +33,13 @@
let isLoading = $state(false);
let showAdvancedOptions = $state(false);
let logo = $state
();
+ let darkLogo = $state();
let logoDataURL: string | null = $state(
existingClient?.hasLogo ? cachedOidcClientLogo.getUrl(existingClient!.id) : null
);
+ let darkLogoDataURL: string | null = $state(
+ existingClient?.hasDarkLogo ? cachedOidcClientLogo.getUrl(existingClient!.id, false) : null
+ );
const client = {
id: '',
@@ -48,7 +53,8 @@
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
},
- logoUrl: ''
+ logoUrl: '',
+ darkLogoUrl: ''
};
const formSchema = z.object({
@@ -70,6 +76,7 @@
requiresReauthentication: z.boolean(),
launchURL: optionalUrl,
logoUrl: optionalUrl,
+ darkLogoUrl: optionalUrl,
credentials: z.object({
federatedIdentities: z.array(
z.object({
@@ -92,37 +99,63 @@
const success = await callback({
...data,
- logo: $inputs.logoUrl?.value ? null : logo,
- logoUrl: $inputs.logoUrl?.value
+ logo: $inputs.logoUrl?.value ? undefined : logo,
+ logoUrl: $inputs.logoUrl?.value,
+ darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
+ darkLogoUrl: $inputs.darkLogoUrl?.value
});
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
- if (success && existingClient && hasLogo) {
- logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
+ const hasDarkLogo = darkLogo != null || !!$inputs.darkLogoUrl?.value;
+ if (success && existingClient) {
+ if (hasLogo) {
+ logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
+ }
+ if (hasDarkLogo) {
+ darkLogoDataURL = cachedOidcClientLogo.getUrl(existingClient.id, false);
+ }
}
if (success && !existingClient) form.reset();
isLoading = false;
}
- function onLogoChange(input: File | string | null) {
+ function onLogoChange(input: File | string | null, light: boolean = true) {
if (input == null) return;
+ const logoUrlInput = light ? $inputs.logoUrl : $inputs.darkLogoUrl;
+
if (typeof input === 'string') {
- logo = null;
- logoDataURL = input || null;
- $inputs.logoUrl!.value = input;
+ if (light) {
+ logo = null;
+ logoDataURL = input || null;
+ } else {
+ darkLogo = null;
+ darkLogoDataURL = input || null;
+ }
+ logoUrlInput!.value = input;
} else {
- logo = input;
- $inputs.logoUrl && ($inputs.logoUrl.value = '');
- logoDataURL = URL.createObjectURL(input);
+ if (light) {
+ logo = input;
+ logoDataURL = URL.createObjectURL(input);
+ } else {
+ darkLogo = input;
+ darkLogoDataURL = URL.createObjectURL(input);
+ }
+ logoUrlInput && (logoUrlInput.value = '');
}
}
- function resetLogo() {
- logo = null;
- logoDataURL = null;
- $inputs.logoUrl && ($inputs.logoUrl.value = '');
+ function resetLogo(light: boolean = true) {
+ if (light) {
+ logo = null;
+ logoDataURL = null;
+ $inputs.logoUrl && ($inputs.logoUrl.value = '');
+ } else {
+ darkLogo = null;
+ darkLogoDataURL = null;
+ $inputs.darkLogoUrl && ($inputs.darkLogoUrl.value = '');
+ }
}
function getFederatedIdentityErrors(errors: z.ZodError | undefined) {
@@ -182,13 +215,49 @@
bind:checked={$inputs.requiresReauthentication.value}
/>
-
-
+
+
+
+ resetLogo(true)}
+ clientName={$inputs.name.value}
+ light={true}
+ onLogoChange={(input) => onLogoChange(input, true)}
+ >
+ {#snippet tabTriggers()}
+
+
+
+
+
+
+
+
+ {/snippet}
+
+
+
+ resetLogo(false)}
+ clientName={$inputs.name.value}
+ onLogoChange={(input) => onLogoChange(input, false)}
+ >
+ {#snippet tabTriggers()}
+
+
+
+
+
+
+
+
+ {/snippet}
+
+
+
{#if showAdvancedOptions}
diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-image-input.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-image-input.svelte
index 61201d3a..8c928aae 100644
--- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-image-input.svelte
+++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-image-input.svelte
@@ -5,40 +5,53 @@
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { LucideX } from '@lucide/svelte';
+ import type { Snippet } from 'svelte';
let {
logoDataURL,
clientName,
resetLogo,
- onLogoChange
+ onLogoChange,
+ light,
+ tabTriggers
}: {
logoDataURL: string | null;
clientName: string;
resetLogo: () => void;
onLogoChange: (file: File | string | null) => void;
+ tabTriggers?: Snippet;
+ light: boolean;
} = $props();
+
+ let id = `oidc-client-logo-${light ? 'light' : 'dark'}`;
-
-
+
+
+
+ {#if tabTriggers}
+ {@render tabTriggers()}
+ {/if}
+
+
+
+
{#if logoDataURL}
{/if}
-
diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte
index 63f1118b..6f2f9d40 100644
--- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte
+++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte
@@ -13,6 +13,7 @@
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte';
+ import { mode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
const oidcService = new OIDCService();
@@ -22,6 +23,8 @@
return tableRef?.refresh();
}
+ const isLightMode = $derived(mode.current === 'light');
+
const booleanFilterValues = [
{ label: m.enabled(), value: true },
{ label: m.disabled(), value: false }
@@ -103,7 +106,7 @@
{#if item.hasLogo}
{:else}
diff --git a/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte
index 96de4ad1..e6e2e325 100644
--- a/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte
+++ b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte
@@ -40,7 +40,7 @@
diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts
index c44c4e22..ed2756c3 100644
--- a/tests/specs/oidc-client-settings.spec.ts
+++ b/tests/specs/oidc-client-settings.spec.ts
@@ -19,7 +19,10 @@ test.describe('Create OIDC client', () => {
await page.getByRole('button', { name: 'Add another' }).click();
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) {
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.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
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`);
expect(res.ok()).toBeTruthy();
@@ -67,14 +70,17 @@ 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.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.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'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
.get(`/api/oidc/clients/${oidcClient.id}/logo`)
.then((res) => expect.soft(res.status()).toBe(200));