feat: add support for dark mode oidc client icons (#1039)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-10-24 02:57:12 -05:00
committed by GitHub
parent eb3963d0fc
commit 028d1c858e
19 changed files with 381 additions and 119 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN dark_image_type;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN dark_image_type TEXT;

View File

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

View File

@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients ADD COLUMN dark_image_type TEXT;
COMMIT;
PRAGMA foreign_keys=ON;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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