diff --git a/backend/internal/apperror/constructors.go b/backend/internal/apperror/constructors.go index 3a73716e..636b4c14 100644 --- a/backend/internal/apperror/constructors.go +++ b/backend/internal/apperror/constructors.go @@ -276,6 +276,14 @@ func LogoTooLarge(maxSize string) *Error { WithDetail("max_size", maxSize) } +func LogoPresetsDisabled() *Error { + return New(CodeLogoPresetsDisabled, http.StatusForbidden, "Logo presets are disabled") +} + +func LogoPresetsUnavailable(cause error) *Error { + return Wrap(cause, CodeLogoPresetsUnavailable, http.StatusBadGateway, "Logo presets could not be loaded") +} + func InvalidLogoURL(cause error) *Error { return Wrap(cause, CodeValidationFailed, http.StatusBadRequest, "Logo URL is not allowed").WithFields([]FieldError{{ Field: "logoUrl", diff --git a/backend/internal/apperror/error.go b/backend/internal/apperror/error.go index 08197f8c..26ea71ac 100644 --- a/backend/internal/apperror/error.go +++ b/backend/internal/apperror/error.go @@ -67,6 +67,8 @@ const ( CodeLogoDownloadFailed Code = "logo_download_failed" CodeLogoTypeNotSupported Code = "logo_type_not_supported" CodeLogoTooLarge Code = "logo_too_large" + CodeLogoPresetsDisabled Code = "logo_presets_disabled" + CodeLogoPresetsUnavailable Code = "logo_presets_unavailable" CodeOidcPARRequired Code = "oidc_par_required" ) diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index fa222ce0..b327d055 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -180,6 +180,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add()) controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) svc.environmentModule.RegisterRoutes(apiGroup, authMiddleware.WithAdminNotRequired().Add()) + svc.logoPresetModule.RegisterRoutes(apiGroup, authMiddleware.Add()) svc.scimSyncModule.RegisterRoutes(apiGroup, authMiddleware.Add()) svc.userSignUpModule.RegisterRoutes(apiGroup, authMiddleware.Add(), diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 272c0810..8439ba62 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -19,6 +19,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/geolite" "github.com/pocket-id/pocket-id/backend/internal/iplocation" "github.com/pocket-id/pocket-id/backend/internal/ldapsync" + "github.com/pocket-id/pocket-id/backend/internal/logopreset" "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/onetimeaccess" "github.com/pocket-id/pocket-id/backend/internal/scimsync" @@ -55,6 +56,7 @@ type services struct { emailVerificationModule *emailverification.Module apiModule *api.Module environmentModule *environment.Module + logoPresetModule *logopreset.Module actors francishost.Host } @@ -258,6 +260,16 @@ func initServices( SQLiteOnNetworkedFilesystem: sqliteOnNetworkedFilesystem, }) + // An empty base URL keeps the icon library disabled + var iconLibraryURL string + if common.EnvConfig.IconLibraryEnabled() { + iconLibraryURL = common.EnvConfig.IconLibraryURL + } + svc.logoPresetModule = logopreset.New(logopreset.Dependencies{ + HTTPClient: httpClient, + BaseURL: iconLibraryURL, + }) + return svc, nil } diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index a3e8eef8..5f6800ed 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -8,6 +8,7 @@ import ( "net" "net/url" "os" + "path" "reflect" "strconv" "strings" @@ -44,6 +45,11 @@ const ( defaultFsUploadPath string = "data/uploads" AppUrl string = "http://localhost:1411" + // IconLibraryDisabled is the ICON_LIBRARY_URL value that turns off the icon library for OIDC client logos + IconLibraryDisabled string = "disabled" + // DefaultIconLibraryURL serves the selfh.st icon collection through jsDelivr + DefaultIconLibraryURL string = "https://cdn.jsdelivr.net/gh/selfhst/icons@main" + // FrancisHostEmbedded is the FRANCIS_HOST value that keeps the Francis actor runtime embedded in the Pocket ID process FrancisHostEmbedded string = "embedded" // francisHostPSKMinLength is the min length for the host bootstrap pre-shared key for Francis @@ -98,6 +104,11 @@ type EnvConfigSchema struct { // CloudflareLocationHeaders trusts location headers supplied by a Cloudflare proxy instead of using GeoLite CloudflareLocationHeaders bool `env:"CLOUDFLARE_LOCATION_HEADERS"` + // IconLibraryURL is the base URL of the icon collection admins can pick OIDC client logos from + // It must serve index.json and the svg and png directories in the same layout as the selfh.st/icons repository + // Setting it to "disabled" turns the icon library off, so Pocket ID never contacts it + IconLibraryURL string `env:"ICON_LIBRARY_URL" options:"trimTrailingSlash"` + ActorsPort string `env:"ACTORS_PORT"` ActorsHost string `env:"ACTORS_HOST" options:"toLower"` @@ -166,6 +177,7 @@ func defaultConfig() EnvConfigSchema { FrancisHost: FrancisHostEmbedded, GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBUrl: MaxMindGeoLiteCityUrl, + IconLibraryURL: DefaultIconLibraryURL, } } @@ -249,6 +261,11 @@ func ValidateEnvConfig(config *EnvConfigSchema) error { return err } + err = validateIconLibraryURL(config) + if err != nil { + return err + } + err = validateLocalIPv6Ranges(config.LocalIPv6Ranges) if err != nil { return err @@ -391,6 +408,48 @@ func validateURLWithoutPath(rawURL, envName string) error { return nil } +// validateIconLibraryURL checks that ICON_LIBRARY_URL is either "disabled" or an absolute HTTP(S) URL +func validateIconLibraryURL(config *EnvConfigSchema) error { + switch { + case config.IconLibraryURL == "": + config.IconLibraryURL = DefaultIconLibraryURL + return nil + case strings.EqualFold(config.IconLibraryURL, IconLibraryDisabled): + config.IconLibraryURL = IconLibraryDisabled + return nil + } + + parsedURL, err := url.Parse(config.IconLibraryURL) + if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" { + return errors.New("ICON_LIBRARY_URL must be an HTTP or HTTPS URL, or 'disabled'") + } + + return nil +} + +// IconLibraryEnabled reports whether admins can pick OIDC client logos from the icon library +func (c *EnvConfigSchema) IconLibraryEnabled() bool { + return c.IconLibraryURL != IconLibraryDisabled +} + +// IsIconLibraryURL reports whether the URL points to a file inside the configured icon library +// Dot segments are rejected so a URL can't climb out of the library's path on the same host +func (c *EnvConfigSchema) IsIconLibraryURL(u *url.URL) bool { + if !c.IconLibraryEnabled() { + return false + } + + base, err := url.Parse(c.IconLibraryURL) + if err != nil { + return false + } + + return u.Scheme == base.Scheme && + strings.EqualFold(u.Host, base.Host) && + u.Path == path.Clean(u.Path) && + strings.HasPrefix(u.Path, base.Path+"/") +} + func validateFileBackend(config *EnvConfigSchema) error { switch config.FileBackend { case "s3", "database": diff --git a/backend/internal/common/env_config_test.go b/backend/internal/common/env_config_test.go index 6845016d..5a0f6702 100644 --- a/backend/internal/common/env_config_test.go +++ b/backend/internal/common/env_config_test.go @@ -1,6 +1,7 @@ package common import ( + "net/url" "os" "testing" @@ -657,3 +658,79 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) { assert.Equal(t, keyContent, EnvConfig.TLSKey) }) } + +func TestIconLibraryConfig(t *testing.T) { + originalConfig := EnvConfig + t.Cleanup(func() { + EnvConfig = originalConfig + }) + + tests := []struct { + name string + value *string + wantURL string + wantEnabled bool + wantErr string + }{ + {name: "defaults to the selfh.st collection", wantURL: DefaultIconLibraryURL, wantEnabled: true}, + {name: "empty value falls back to the default", value: new(""), wantURL: DefaultIconLibraryURL, wantEnabled: true}, + {name: "custom URL without the trailing slash", value: new("http://192.168.1.10:4050/icons/"), wantURL: "http://192.168.1.10:4050/icons", wantEnabled: true}, + {name: "disabled is case-insensitive", value: new("Disabled"), wantURL: IconLibraryDisabled, wantEnabled: false}, + {name: "rejects other schemes", value: new("ftp://mirror.example.com/icons"), wantErr: "ICON_LIBRARY_URL must be an HTTP or HTTPS URL"}, + {name: "rejects values without a host", value: new("icons"), wantErr: "ICON_LIBRARY_URL must be an HTTP or HTTPS URL"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + EnvConfig = defaultConfig() + if tt.value != nil { + t.Setenv("ICON_LIBRARY_URL", *tt.value) + } + + err := parseAndValidateEnvConfig(t) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantURL, EnvConfig.IconLibraryURL) + assert.Equal(t, tt.wantEnabled, EnvConfig.IconLibraryEnabled()) + }) + } +} + +func TestIsIconLibraryURL(t *testing.T) { + config := EnvConfigSchema{IconLibraryURL: "http://mirror.lan:4050/icons"} + + tests := []struct { + name string + url string + want bool + }{ + {name: "file inside the library", url: "http://mirror.lan:4050/icons/svg/nextcloud.svg", want: true}, + {name: "host is matched case-insensitively", url: "http://MIRROR.lan:4050/icons/svg/nextcloud.svg", want: true}, + {name: "path outside the library", url: "http://mirror.lan:4050/admin/logo.svg", want: false}, + {name: "path sharing the library prefix", url: "http://mirror.lan:4050/icons-private/logo.svg", want: false}, + {name: "dot segments climbing out of the library", url: "http://mirror.lan:4050/icons/../admin/logo.svg", want: false}, + {name: "encoded dot segments", url: "http://mirror.lan:4050/icons/%2e%2e/admin/logo.svg", want: false}, + {name: "host that starts with the library host", url: "http://mirror.lan.evil.com:4050/icons/svg/nextcloud.svg", want: false}, + {name: "different port", url: "http://mirror.lan:8080/icons/svg/nextcloud.svg", want: false}, + {name: "different scheme", url: "https://mirror.lan:4050/icons/svg/nextcloud.svg", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.url) + require.NoError(t, err) + assert.Equal(t, tt.want, config.IsIconLibraryURL(u)) + }) + } + + t.Run("nothing is inside a disabled library", func(t *testing.T) { + disabled := EnvConfigSchema{IconLibraryURL: IconLibraryDisabled} + u, err := url.Parse("http://disabled/svg/nextcloud.svg") + require.NoError(t, err) + assert.False(t, disabled.IsIconLibraryURL(u)) + }) +} diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index f1886d53..441a86be 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -79,10 +79,28 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) error { Value: strconv.FormatBool(tracing.FrontendTracingEnabled()), }) + // Manually add iconLibrary, derived from ICON_LIBRARY_URL, so the frontend can hide the icon picker and only credits selfh.st when its collection is used + configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{ + Key: "iconLibrary", + Value: iconLibraryStatus(), + }) + c.JSON(http.StatusOK, configVariablesDto) return nil } +// iconLibraryStatus reports whether the icon library is disabled, uses the default selfh.st collection, or points to a custom source +func iconLibraryStatus() string { + switch common.EnvConfig.IconLibraryURL { + case common.IconLibraryDisabled: + return "disabled" + case common.DefaultIconLibraryURL: + return "default" + default: + return "custom" + } +} + // listAllAppConfigHandler godoc // @Summary List all application configurations // @Description Get all application configurations including private ones diff --git a/backend/internal/logopreset/dto.go b/backend/internal/logopreset/dto.go new file mode 100644 index 00000000..d18da045 --- /dev/null +++ b/backend/internal/logopreset/dto.go @@ -0,0 +1,8 @@ +package logopreset + +type logoPresetDto struct { + Name string `json:"name"` + Reference string `json:"reference"` + LogoURL string `json:"logoUrl"` + DarkLogoURL *string `json:"darkLogoUrl"` +} diff --git a/backend/internal/logopreset/handler.go b/backend/internal/logopreset/handler.go new file mode 100644 index 00000000..2fc6fdac --- /dev/null +++ b/backend/internal/logopreset/handler.go @@ -0,0 +1,36 @@ +package logopreset + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + _ "github.com/pocket-id/pocket-id/backend/internal/dto" +) + +type handler struct { + service *Service +} + +func newHandler(service *Service) *handler { + return &handler{service: service} +} + +// search godoc +// @Summary Search logo presets +// @Description Search the icon library for logos that can be used for OIDC clients +// @Tags OIDC +// @Produce json +// @Param search query string false "Search term matched against the icon name" +// @Success 200 {array} logoPresetDto +// @Failure default {object} dto.ErrorDto "Error" +// @Router /api/oidc/logo-presets [get] +func (h *handler) search(c *gin.Context) error { + presets, err := h.service.Search(c.Request.Context(), c.Query("search")) + if err != nil { + return err + } + + c.JSON(http.StatusOK, presets) + return nil +} diff --git a/backend/internal/logopreset/module.go b/backend/internal/logopreset/module.go new file mode 100644 index 00000000..9a7e7fdb --- /dev/null +++ b/backend/internal/logopreset/module.go @@ -0,0 +1,36 @@ +package logopreset + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/pocket-id/pocket-id/backend/internal/httpserver" +) + +type Dependencies struct { + HTTPClient *http.Client + + // BaseURL is the root of the icon library, or empty when the icon library is disabled + BaseURL string +} + +// Module lets admins search an icon collection in the selfh.st/icons layout for OIDC client logos +// The chosen icon is downloaded through the regular logo URL flow of the OIDC client update +type Module struct { + service *Service + handler *handler +} + +func New(deps Dependencies) *Module { + service := newService(deps) + return &Module{ + service: service, + handler: newHandler(service), + } +} + +// RegisterRoutes mounts the logo preset endpoints +func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth gin.HandlerFunc) { + apiGroup.GET("/oidc/logo-presets", auth, httpserver.Handle(m.handler.search)) +} diff --git a/backend/internal/logopreset/service.go b/backend/internal/logopreset/service.go new file mode 100644 index 00000000..5cdcc641 --- /dev/null +++ b/backend/internal/logopreset/service.go @@ -0,0 +1,224 @@ +package logopreset + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "regexp" + "slices" + "strings" + "time" + + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +const ( + indexTTL = 24 * time.Hour + fetchTimeout = 10 * time.Second + maxIndexSize = 5 << 20 + maxResults = 30 +) + +// referencePattern matches the slugs selfh.st uses as file names, so a reference can never escape its directory in an icon URL +var referencePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// nonAlphanumeric is stripped from names and queries so "home assistant" matches "Home-Assistant" +var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`) + +// indexEntry is one icon in selfh.st's index.json, whose availability flags are the strings "Yes" or "No" +type indexEntry struct { + Name string `json:"Name"` + Reference string `json:"Reference"` + SVG string `json:"SVG"` + Light string `json:"Light"` + Tags string `json:"Tags"` +} + +// preset is an index entry with its search keys normalized once when the index is loaded +type preset struct { + indexEntry + + searchName string + searchReference string + searchTags string +} + +type Service struct { + httpClient *http.Client + baseURL string + cache *utils.Cache[[]preset] +} + +func newService(deps Dependencies) *Service { + return &Service{ + httpClient: deps.HTTPClient, + baseURL: deps.BaseURL, + cache: utils.New[[]preset](indexTTL), + } +} + +// Search returns the icons that best match the query, ranked from exact to loose matches +func (s *Service) Search(ctx context.Context, query string) ([]logoPresetDto, error) { + // Operators can turn the icon library off so Pocket ID never contacts it + if s.baseURL == "" { + return nil, apperror.LogoPresetsDisabled() + } + + presets, err := s.getIndex(ctx) + if err != nil { + return nil, apperror.LogoPresetsUnavailable(err) + } + + matches := search(presets, query, maxResults) + result := make([]logoPresetDto, len(matches)) + for i, p := range matches { + result[i] = s.toDto(p) + } + + return result, nil +} + +func (s *Service) getIndex(ctx context.Context) ([]preset, error) { + presets, err := s.cache.GetOrFetch(ctx, s.fetchIndex) + + // A stale index is still good enough to search while the CDN is unreachable + if staleErr, ok := errors.AsType[*utils.ErrStale](err); ok { + slog.WarnContext(ctx, "Failed to refresh logo preset index, using stale cache", slog.Any("error", staleErr.Err)) + return presets, nil + } + + return presets, err +} + +func (s *Service) fetchIndex(ctx context.Context) ([]preset, error) { + reqCtx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + // Download the index that lists every icon in the collection + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, s.baseURL+"/index.json", nil) + if err != nil { + return nil, fmt.Errorf("create icon index request: %w", err) + } + req.Header.Set("User-Agent", "pocket-id/logo-presets") + req.Header.Set("Accept", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch icon index: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("icon index returned status %d", resp.StatusCode) + } + + var entries []indexEntry + err = json.NewDecoder(utils.NewLimitReader(resp.Body, maxIndexSize)).Decode(&entries) + if err != nil { + return nil, fmt.Errorf("decode icon index: %w", err) + } + + // Skip entries whose reference can't safely be used as a file name in an icon URL + presets := make([]preset, 0, len(entries)) + for _, e := range entries { + if !referencePattern.MatchString(e.Reference) { + continue + } + + presets = append(presets, preset{ + indexEntry: e, + searchName: normalize(e.Name), + searchReference: normalize(e.Reference), + searchTags: strings.ToLower(e.Tags), + }) + } + + return presets, nil +} + +// search returns up to limit presets matching the query, ordered by match quality and then by name +func search(presets []preset, query string, limit int) []preset { + normalizedQuery := normalize(query) + tagQuery := strings.ToLower(strings.TrimSpace(query)) + + type match struct { + preset preset + rank int + } + + // Rank every preset that matches the query at all + matches := make([]match, 0, limit) + for _, p := range presets { + rank, ok := matchRank(p, normalizedQuery, tagQuery) + if ok { + matches = append(matches, match{preset: p, rank: rank}) + } + } + + // Lower ranks are better matches, and ties are broken alphabetically so results are stable + slices.SortFunc(matches, func(a, b match) int { + return cmp.Or( + cmp.Compare(a.rank, b.rank), + cmp.Compare(a.preset.searchName, b.preset.searchName), + ) + }) + + result := make([]preset, 0, min(limit, len(matches))) + for _, m := range matches[:min(limit, len(matches))] { + result = append(result, m.preset) + } + + return result +} + +// matchRank reports how well a preset matches the query, where 0 is an exact match +func matchRank(p preset, normalizedQuery, tagQuery string) (int, bool) { + switch { + case normalizedQuery == "": + return 0, true + case p.searchName == normalizedQuery || p.searchReference == normalizedQuery: + return 0, true + case strings.HasPrefix(p.searchName, normalizedQuery) || strings.HasPrefix(p.searchReference, normalizedQuery): + return 1, true + case strings.Contains(p.searchName, normalizedQuery) || strings.Contains(p.searchReference, normalizedQuery): + return 2, true + case tagQuery != "" && strings.Contains(p.searchTags, tagQuery): + return 3, true + default: + return 0, false + } +} + +func (s *Service) toDto(p preset) logoPresetDto { + format := "png" + if p.SVG == "Yes" { + format = "svg" + } + + dto := logoPresetDto{ + Name: p.Name, + Reference: p.Reference, + LogoURL: s.iconURL(format, p.Reference), + } + + // The white variant keeps monochrome logos readable on dark backgrounds + if p.Light == "Yes" { + darkLogoURL := s.iconURL(format, p.Reference+"-light") + dto.DarkLogoURL = &darkLogoURL + } + + return dto +} + +func (s *Service) iconURL(format, name string) string { + return s.baseURL + "/" + format + "/" + name + "." + format +} + +func normalize(s string) string { + return nonAlphanumeric.ReplaceAllString(strings.ToLower(s), "") +} diff --git a/backend/internal/logopreset/service_test.go b/backend/internal/logopreset/service_test.go new file mode 100644 index 00000000..8dfbd983 --- /dev/null +++ b/backend/internal/logopreset/service_test.go @@ -0,0 +1,180 @@ +package logopreset + +import ( + "errors" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/utils" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +const testBaseURL = "https://icons.example.com" + +const testIndex = `[ + {"Name": "Nextcloud", "Reference": "nextcloud", "SVG": "Yes", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": "Cloud Storage"}, + {"Name": "Nextcloud Talk", "Reference": "nextcloud-talk", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": ""}, + {"Name": "Ghost", "Reference": "ghost", "SVG": "No", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": "Blogging"}, + {"Name": "Home Assistant", "Reference": "home-assistant", "SVG": "Yes", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": ""}, + {"Name": "OwnCloud", "Reference": "owncloud", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": "Cloud Storage"}, + {"Name": "Bad", "Reference": "../bad", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": ""} +]` + +// countingRoundTripper counts requests before delegating to the mock, so tests can assert when the index is fetched +type countingRoundTripper struct { + next http.RoundTripper + calls atomic.Int32 +} + +func (c *countingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + c.calls.Add(1) + return c.next.RoundTrip(req) +} + +func newTestService(t *testing.T, mock *testutils.MockRoundTripper) (*Service, *countingRoundTripper) { + t.Helper() + + transport := &countingRoundTripper{next: mock} + return newService(Dependencies{HTTPClient: &http.Client{Transport: transport}, BaseURL: testBaseURL}), transport +} + +func indexMock() *testutils.MockRoundTripper { + return &testutils.MockRoundTripper{ + Responses: map[string]*http.Response{ + testBaseURL + "/index.json": testutils.NewMockResponse(http.StatusOK, testIndex), //nolint:bodyclose // mock response, no real body + }, + } +} + +func references(presets []logoPresetDto) []string { + refs := make([]string, len(presets)) + for i, p := range presets { + refs[i] = p.Reference + } + return refs +} + +func TestSearch_Disabled(t *testing.T) { + svc, transport := newTestService(t, indexMock()) + svc.baseURL = "" + + _, err := svc.Search(t.Context(), "nextcloud") + + require.Error(t, err) + assert.True(t, apperror.IsCode(err, apperror.CodeLogoPresetsDisabled)) + assert.Zero(t, transport.calls.Load(), "a disabled icon library must not be contacted") +} + +func TestSearch_Ranking(t *testing.T) { + svc, _ := newTestService(t, indexMock()) + + tests := []struct { + name string + query string + want []string + }{ + {name: "exact match ranks before prefix match", query: "nextcloud", want: []string{"nextcloud", "nextcloud-talk"}}, + {name: "substring matches come after prefix matches", query: "cloud", want: []string{"nextcloud", "nextcloud-talk", "owncloud"}}, + {name: "separators and case are ignored", query: "HOME assistant", want: []string{"home-assistant"}}, + {name: "tags match last", query: "blogging", want: []string{"ghost"}}, + {name: "empty query lists everything alphabetically", query: "", want: []string{"ghost", "home-assistant", "nextcloud", "nextcloud-talk", "owncloud"}}, + {name: "no match", query: "jellyfin", want: []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + presets, err := svc.Search(t.Context(), tt.query) + require.NoError(t, err) + assert.Equal(t, tt.want, references(presets)) + }) + } +} + +func TestSearch_LimitsResults(t *testing.T) { + presets := make([]preset, maxResults+10) + for i := range presets { + presets[i] = preset{searchName: "app", searchReference: "app"} + } + + assert.Len(t, search(presets, "app", maxResults), maxResults) +} + +func TestSearch_BuildsIconURLs(t *testing.T) { + svc, _ := newTestService(t, indexMock()) + + presets, err := svc.Search(t.Context(), "") + require.NoError(t, err) + + byReference := make(map[string]logoPresetDto, len(presets)) + for _, p := range presets { + byReference[p.Reference] = p + } + + // SVG icon with a white variant for dark mode + nextcloud := byReference["nextcloud"] + assert.Equal(t, testBaseURL+"/svg/nextcloud.svg", nextcloud.LogoURL) + require.NotNil(t, nextcloud.DarkLogoURL) + assert.Equal(t, testBaseURL+"/svg/nextcloud-light.svg", *nextcloud.DarkLogoURL) + + // SVG icon without a white variant leaves the dark logo empty + talk := byReference["nextcloud-talk"] + assert.Equal(t, testBaseURL+"/svg/nextcloud-talk.svg", talk.LogoURL) + assert.Nil(t, talk.DarkLogoURL) + + // PNG-only icon falls back to PNG for both variants + ghost := byReference["ghost"] + assert.Equal(t, testBaseURL+"/png/ghost.png", ghost.LogoURL) + require.NotNil(t, ghost.DarkLogoURL) + assert.Equal(t, testBaseURL+"/png/ghost-light.png", *ghost.DarkLogoURL) + + // References that aren't plain slugs are dropped + assert.NotContains(t, byReference, "../bad") +} + +func TestSearch_CachesIndex(t *testing.T) { + svc, transport := newTestService(t, indexMock()) + + for range 3 { + _, err := svc.Search(t.Context(), "nextcloud") + require.NoError(t, err) + } + + assert.Equal(t, int32(1), transport.calls.Load()) +} + +func TestSearch_UsesStaleIndexWhenRefreshFails(t *testing.T) { + mock := indexMock() + svc, _ := newTestService(t, mock) + + // Expire the cache immediately so the second search has to refetch + svc.cache = utils.New[[]preset](time.Nanosecond) + + _, err := svc.Search(t.Context(), "nextcloud") + require.NoError(t, err) + + mock.Err = errors.New("CDN unreachable") + + presets, err := svc.Search(t.Context(), "nextcloud") + require.NoError(t, err) + assert.Equal(t, []string{"nextcloud", "nextcloud-talk"}, references(presets)) +} + +func TestSearch_IndexUnavailable(t *testing.T) { + mock := &testutils.MockRoundTripper{ + Responses: map[string]*http.Response{ + testBaseURL + "/index.json": testutils.NewMockResponse(http.StatusInternalServerError, ""), //nolint:bodyclose // mock response, no real body + }, + } + svc, _ := newTestService(t, mock) + + _, err := svc.Search(t.Context(), "nextcloud") + + require.Error(t, err) + assert.True(t, apperror.IsCode(err, apperror.CodeLogoPresetsUnavailable)) +} diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index c3953a24..77de4869 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -21,6 +21,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/backchannellogout" + "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -522,15 +523,14 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light return nil, 0, "", err } + // Each variant falls back to the other one, so a client with a single logo shows it in both themes var suffix string var ext string switch { - case !light && client.DarkImageType != nil: - // Dark logo if requested and exists + case client.HasDarkLogo() && (!light || !client.HasLogo()): suffix = "-dark" ext = *client.DarkImageType - case client.ImageType != nil: - // Light logo if requested or no dark logo is available + case client.HasLogo(): ext = *client.ImageType default: return nil, 0, "", apperror.ImageNotFound() @@ -943,6 +943,23 @@ func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *ht return client } +// checkLogoURLAllowed prevents SSRF by allowing only URLs that resolve to public IPs +// URLs inside the icon library are exempt because the operator configured it, which lets a self-hosted mirror live on the local network +func checkLogoURLAllowed(ctx context.Context, u *url.URL) error { + if common.EnvConfig.IsIconLibraryURL(u) { + return nil + } + + private, err := utils.IsURLPrivate(ctx, u) + if err != nil { + return apperror.LogoDownloadFailed(err) + } else if private { + return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed")) + } + + return nil +} + func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clientID string, raw string, light bool) error { u, err := url.Parse(raw) if err != nil { @@ -955,12 +972,9 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second) defer cancel() - // Prevents SSRF by allowing only public IPs - ok, err := utils.IsURLPrivate(ctx, u) + err = checkLogoURLAllowed(ctx, u) if err != nil { - return apperror.LogoDownloadFailed(err) - } else if ok { - return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed")) + return err } // We need to check this on redirects too @@ -969,14 +983,7 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie return apperror.InvalidLogoURL(errors.New("stopped after 10 redirects")) } - ok, err := utils.IsURLPrivate(r.Context(), r.URL) - if err != nil { - return err - } else if ok { - return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed")) - } - - return nil + return checkLogoURLAllowed(r.Context(), r.URL) }) req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go index b8e41fbd..d45af17d 100644 --- a/backend/internal/service/oidc_service_test.go +++ b/backend/internal/service/oidc_service_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -413,6 +414,41 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) }) + t.Run("Allows private hosts inside the icon library only", func(t *testing.T) { + const iconLibraryURL = "http://127.0.0.1:4050/icons" + originalIconLibraryURL := common.EnvConfig.IconLibraryURL + common.EnvConfig.IconLibraryURL = iconLibraryURL + t.Cleanup(func() { + common.EnvConfig.IconLibraryURL = originalIconLibraryURL + }) + + //nolint:bodyclose + svgResponse := testutils.NewMockResponse(http.StatusOK, ``) + svgResponse.Header.Set("Content-Type", "image/svg+xml") + + s := &OidcService{ + db: db, + fileStorage: dbStorage, + httpClient: &http.Client{ + Transport: &testutils.MockRoundTripper{ + Responses: map[string]*http.Response{ + iconLibraryURL + "/svg/nextcloud.svg": svgResponse, + }, + }, + }, + } + + // The operator configured the library, so its loopback address is trusted + err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, iconLibraryURL+"/svg/nextcloud.svg", true) + require.NoError(t, err) + require.True(t, fileExists(t, "oidc-client-images/"+client.ID+".svg")) + + // Other paths on the same private host are still blocked + err = s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "http://127.0.0.1:4050/admin/logo.svg", true) + require.Error(t, err) + require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) + }) + t.Run("Returns error for non-200 status code", func(t *testing.T) { mockResponses := map[string]*http.Response{ //nolint:bodyclose @@ -1074,3 +1110,69 @@ func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string { } return names } + +func TestOidcService_GetClientLogo(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + dbStorage, err := storage.NewDatabaseStorage(db) + require.NoError(t, err) + s := &OidcService{db: db, fileStorage: dbStorage} + + // createClient stores the given variants with their name as content, so the test can tell which one was served + createClient := func(t *testing.T, light, dark bool) string { + t.Helper() + + client := model.OidcClient{Name: "Logo Client", CallbackURLs: datatype.StringList{"https://example.com/callback"}} + if light { + client.ImageType = new("png") + } + if dark { + client.DarkImageType = new("png") + } + require.NoError(t, db.Create(&client).Error) + + if light { + require.NoError(t, dbStorage.Save(t.Context(), oidcClientImagePath(client.ID, "", "png"), strings.NewReader("light"))) + } + if dark { + require.NoError(t, dbStorage.Save(t.Context(), oidcClientImagePath(client.ID, "-dark", "png"), strings.NewReader("dark"))) + } + return client.ID + } + + tests := []struct { + name string + light bool + dark bool + wantLight string + wantDark string + }{ + {name: "serves each variant when both exist", light: true, dark: true, wantLight: "light", wantDark: "dark"}, + {name: "dark mode falls back to the light logo", light: true, wantLight: "light", wantDark: "light"}, + {name: "light mode falls back to the dark logo", dark: true, wantLight: "dark", wantDark: "dark"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clientID := createClient(t, tt.light, tt.dark) + + for requestLight, want := range map[bool]string{true: tt.wantLight, false: tt.wantDark} { + reader, _, mimeType, err := s.GetClientLogo(t.Context(), clientID, requestLight) + require.NoError(t, err) + content, err := io.ReadAll(reader) + reader.Close() + require.NoError(t, err) + assert.Equal(t, want, string(content), "light=%t", requestLight) + assert.Equal(t, "image/png", mimeType) + } + }) + } + + t.Run("returns not found without any logo", func(t *testing.T) { + clientID := createClient(t, false, false) + + for _, requestLight := range []bool{true, false} { + _, _, _, err := s.GetClientLogo(t.Context(), clientID, requestLight) + require.True(t, apperror.IsCode(err, apperror.CodeImageNotFound), "light=%t", requestLight) + } + }) +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 45b1e915..4e6c066e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -147,7 +147,6 @@ "username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols", "username_must_start_with": "Username must start with an alphanumeric character", "username_must_end_with": "Username must end with an alphanumeric character", - "sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.", "or_visit": "or visit", "added_on": "Added on", "rename": "Rename", @@ -301,7 +300,6 @@ "requires_pushed_authorization_requests": "Requires Pushed Authorization Requests", "requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint to pre-register authorization parameters before initiating the flow.", "name_logo": "{name} logo", - "upload_logo": "Upload Logo", "are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?", "oidc_client_deleted_successfully": "OIDC client deleted successfully", "authorization_url": "Authorization URL", @@ -323,7 +321,6 @@ "cimd_url_allowlist_description": "Restrict which client ID metadata document URLs are accepted. {#link href=|https://pocket-id.org/docs/advanced/callback-url-wildcards|}Wildcards{/link} are supported. An empty list blocks all URLs.", "refresh": "Refresh", "oidc_client_metadata_refreshed_successfully": "Client metadata document refreshed successfully", - "generate": "Generate", "new_client_secret_created_successfully": "New client secret created successfully", "oidc_client_name": "OIDC Client {name}", "client_id": "Client ID", @@ -331,9 +328,7 @@ "show_more_details": "Show more details", "allowed_user_groups": "Allowed User Groups", "allowed_user_groups_description": "Select the user groups whose members are allowed to sign in to this client.", - "allowed_user_groups_status_unrestricted_description": "No user group restrictions are applied. Any user can sign in to this client.", "unrestrict": "Unrestrict", - "restrict": "Restrict", "user_groups_restriction_updated_successfully": "User groups restriction updated successfully", "favicon": "Favicon", "light_mode_logo": "Light Mode Logo", @@ -431,7 +426,6 @@ "userinfo": "Userinfo", "copy": "Copy", "copy_all": "Copy All", - "show": "Show", "select_an_option": "Select an option", "select_user": "Select User", "error": "Error", @@ -509,8 +503,6 @@ "configure_application_images": "Configure Application Images", "ui_config_disabled_info_title": "UI Configuration Disabled", "ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable.", - "logo_from_url_description": "Paste a direct image URL (svg, png, webp). Find icons at {#link href=|https://selfh.st/icons|}Selfh.st Icons{/link} or {#link href=|https://dashboardicons.com|}Dashboard Icons{/link}.", - "invalid_url": "Invalid URL", "invalid_uri": "Invalid URI", "require_user_email": "Require Email Address", "require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address.", @@ -581,14 +573,12 @@ "api_permissions_description": "The permissions (scopes) that clients can request for this API.", "api_permission_key": "Permission", "add_permission": "Add permission", - "type": "Type", "api_created_successfully": "API created successfully", "api_deleted_successfully": "API deleted successfully", "are_you_sure_you_want_to_delete_this_api": "Are you sure you want to delete this API? Clients will lose access to its permissions.", "api_access": "API access", "api_access_description": "Select which APIs this client may request tokens for on behalf of users (user-delegated access) and for itself via the client credentials grant (client access), and which permissions it may ask for.", "api_access_updated_successfully": "API access updated successfully", - "no_apis_defined_yet": "No APIs have been defined yet. APIs allow clients to request access tokens for specific resources and permissions.", "access_an_api_on_your_behalf": "Access an API on your behalf", "api_name": "API Name", "user_delegated_access": "User-delegated access", @@ -600,7 +590,6 @@ "client_access_description": "The client may request tokens for this API for itself, without a user.", "no_access": "No access", "metadata_document_client_access": "Metadata document clients", - "metadata_document_client_access_description": "Grant access to clients that register themselves through a Client ID Metadata Document (CIMD), such as MCP servers.", "allow_all_metadata_document_clients": "Allow all metadata document clients", "allow_all_metadata_document_clients_description": "Every client registered through a metadata document can request tokens for this API on behalf of users.", "granted_permissions": "Granted permissions", @@ -608,8 +597,6 @@ "access_granted_through_cimd_access": "This API allows all metadata document clients, so the access granted that way is shown here but can only be changed on the API itself.", "access": "Access", "api_access_card_description": "Choose which clients may request tokens for this API and which permissions they may ask for.", - "api_clients_description": "The clients that were granted access to this API individually.", - "no_clients_have_access_to_this_api": "No client has been granted access to this API yet.", "add_client": "Add client", "select_a_client_to_grant_access_to_this_api": "Select the client that should get access to this API.", "revoke_access_for_name": "Revoke access for {name}", @@ -654,8 +641,6 @@ "you_have_unsaved_changes": "You have unsaved changes", "changes_saved_successfully": "Changes saved successfully", "discard": "Discard", - "discard_unsaved_changes": "Discard unsaved changes?", - "discard_unsaved_changes_confirm": "You have unsaved changes on this page. Are you sure you want to discard them?", "please_fix_the_errors_before_saving": "Please fix the highlighted errors before saving", "application": "Application", "oidc_client_application_description": "How the client is shown to users on the consent screen and the My Apps page.", @@ -670,7 +655,15 @@ "add_callback_url": "Add callback URL", "add_logout_url": "Add logout URL", "remove_logo": "Remove logo", - "use_image_url": "Use an image URL", + "choose_logo": "Choose logo", + "current_logo": "Current logo", + "upload_light_logo": "Upload light logo", + "upload_dark_logo": "Upload dark logo", + "search_icons_or_paste_url": "Search icons or paste an image URL", + "paste_image_url": "Paste an image URL", + "use_as_light_logo": "Use as light logo", + "use_as_dark_logo": "Use as dark logo", + "icons_provided_by_selfhst": "Icons by {#link href=|https://selfh.st/icons|}selfh.st{/link}, licensed under {#link href=|https://creativecommons.org/licenses/by/4.0/|}CC BY 4.0{/link}.", "claims": "Claims", "set_custom_client_id": "Set custom client ID", "client_type": "Client type", diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index 26ba1fa7..ee4bd892 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -8,6 +8,7 @@ import type { OidcClient, OidcClientCreate, OidcClientCreated, + OidcClientLogoPreset, OidcClientMetaData, OidcClientSecret, OidcClientSecretCreated, @@ -81,14 +82,21 @@ class OidcService extends APIService { await this.api.post(`/oidc/clients/${encodeClientIdParam(client.id)}/logo`, formData, { params: { light } }); - cachedOidcClientLogo.bustCache(client.id, light); + cachedOidcClientLogo.bustCache(client.id); }; removeClientLogo = async (id: string, light: boolean = true) => { await this.api.delete(`/oidc/clients/${encodeClientIdParam(id)}/logo`, { params: { light } }); - cachedOidcClientLogo.bustCache(id, light); + cachedOidcClientLogo.bustCache(id); + }; + + searchLogoPresets = async (search: string) => { + const { data } = await this.api.get('/oidc/logo-presets', { + params: { search } + }); + return data; }; listClientSecrets = async (id: string) => diff --git a/frontend/src/lib/types/application-configuration.type.ts b/frontend/src/lib/types/application-configuration.type.ts index c8e87120..47257921 100644 --- a/frontend/src/lib/types/application-configuration.type.ts +++ b/frontend/src/lib/types/application-configuration.type.ts @@ -14,6 +14,7 @@ export type AppConfig = { accentColor: string; requireUserEmail: boolean; tracingEnabled: boolean; + iconLibrary: 'default' | 'custom' | 'disabled'; }; export type AllAppConfig = AppConfig & { diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index c9f46a2c..365220d3 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -104,6 +104,13 @@ export type OidcClientCreateWithLogo = OidcClientCreate & { darkLogoUrl?: string; }; +export type OidcClientLogoPreset = { + name: string; + reference: string; + logoUrl: string; + darkLogoUrl: string | null; +}; + export type OidcDeviceCodeInfo = { scope: string[]; scopeInfo: InteractionScopeInfo[]; diff --git a/frontend/src/lib/utils/cached-image-util.ts b/frontend/src/lib/utils/cached-image-util.ts index 8f592285..8f3bc79d 100644 --- a/frontend/src/lib/utils/cached-image-util.ts +++ b/frontend/src/lib/utils/cached-image-util.ts @@ -57,24 +57,23 @@ export const cachedProfilePicture: CachableImage = { }; export const cachedOidcClientLogo: CachableImage = { - getUrl: (clientId: string, light = true) => { - const url = new URL( - `/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`, - window.location.origin - ); - if (!light) url.searchParams.set('light', 'false'); - return getCachedImageUrl(url); - }, - bustCache: (clientId: string, light = true) => { - const url = new URL( - `/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`, - window.location.origin - ); - if (!light) url.searchParams.set('light', 'false'); - bustImageCache(url); + getUrl: (clientId: string, light = true) => getCachedImageUrl(oidcClientLogoUrl(clientId, light)), + // Each variant falls back to the other one, so changing either logo can change what both URLs serve + bustCache: (clientId: string) => { + bustImageCache(oidcClientLogoUrl(clientId, true)); + bustImageCache(oidcClientLogoUrl(clientId, false)); } }; +function oidcClientLogoUrl(clientId: string, light: boolean) { + const url = new URL( + `/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`, + window.location.origin + ); + if (!light) url.searchParams.set('light', 'false'); + return url; +} + // Builds the URL of an authenticator icon // Unlike the images above these are static assets that only change with a new release, so they skip the cache busting helper and rely on the backend's cache headers export function authenticatorIconUrl(aaguid: string, light = true) { diff --git a/frontend/src/routes/authorize/components/client-provider-images.svelte b/frontend/src/routes/authorize/components/client-provider-images.svelte index 0949039c..43b82496 100644 --- a/frontend/src/routes/authorize/components/client-provider-images.svelte +++ b/frontend/src/routes/authorize/components/client-provider-images.svelte @@ -122,7 +122,7 @@ >
- {#if client?.hasLogo} + {#if client?.hasLogo || client?.hasDarkLogo} - +
{#snippet LogoCell({ item }: { item: ApiClient })} - + {/snippet} 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 c269193a..a3f6c172 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte @@ -67,11 +67,8 @@ const [savedClient] = await Promise.all([dataPromise, imagePromise, darkImagePromise]); Object.assign(client, savedClient); - if (updatedClient.logoUrl) { - cachedOidcClientLogo.bustCache(client.id, true); - } - if (updatedClient.darkLogoUrl) { - cachedOidcClientLogo.bustCache(client.id, false); + if (updatedClient.logoUrl || updatedClient.darkLogoUrl) { + cachedOidcClientLogo.bustCache(client.id); } // Update the hasLogo and hasDarkLogo flags after successful upload 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 169d9775..b55506fc 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 @@ -4,19 +4,22 @@ import * as Card from '#lib/components/ui/card/index.ts'; import * as Field from '#lib/components/ui/field/index.ts'; import { Switch } from '#lib/components/ui/switch/index.ts'; - import * as Tabs from '#lib/components/ui/tabs/index.ts'; import { m } from '#lib/paraglide/messages.js'; - import type { OidcClient, OidcClientCreateWithLogo } from '#lib/types/oidc.type.ts'; + import appConfigStore from '#lib/stores/application-configuration-store.ts'; + import type { + OidcClient, + OidcClientCreateWithLogo, + OidcClientLogoPreset + } from '#lib/types/oidc.type.ts'; import { cachedOidcClientLogo } from '#lib/utils/cached-image-util.ts'; import { axiosErrorToast } from '#lib/utils/error-util.ts'; import { preventDefault } from '#lib/utils/event-util.ts'; import { createForm, type FormInput as FormInputState } from '#lib/utils/form-util.ts'; import { trackFormChanges } from '#lib/utils/unsaved-changes-util.svelte.ts'; import { callbackUrlSchema, optionalUrl } from '#lib/utils/zod-util.ts'; - import { LucideMoon, LucideSun } from '@lucide/svelte'; import { z } from 'zod/v4'; import OidcCallbackUrlInput from './oidc-callback-url-input.svelte'; - import OidcClientImageInput from './oidc-client-image-input.svelte'; + import OidcClientLogoPicker from './oidc-client-logo-picker.svelte'; let { callback, @@ -152,6 +155,16 @@ } } + // A preset fills both variants, and clears the dark logo when the icon has no dark variant so a stale one isn't kept + function selectPreset(preset: OidcClientLogoPreset) { + onLogoChange(preset.logoUrl, true); + if (preset.darkLogoUrl) { + onLogoChange(preset.darkLogoUrl, false); + } else { + resetLogo(false); + } + } + function resetLogo(light: boolean = true) { if (light) { logo = null; @@ -287,40 +300,19 @@ /> {/snippet} -{#snippet logoTabTriggers()} - - - - - - - - -{/snippet} - {#snippet logoInput()} - - - resetLogo(true)} - clientName={$inputs.name.value} - light={true} - onLogoChange={(input) => onLogoChange(input, true)} - tabTriggers={logoTabTriggers} - /> - - - resetLogo(false)} - clientName={$inputs.name.value} - onLogoChange={(input) => onLogoChange(input, false)} - tabTriggers={logoTabTriggers} - /> - - + { + resetLogo(true); + resetLogo(false); + }} + /> {/snippet} 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 deleted file mode 100644 index 0f86670e..00000000 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-image-input.svelte +++ /dev/null @@ -1,147 +0,0 @@ - - -
-
{ - e.preventDefault(); - isDraggingOver = true; - }} - ondragleave={() => (isDraggingOver = false)} - ondrop={onDrop} - > - (e.target.value = '')} - class={cn( - 'text-muted-foreground hover:bg-muted/50 flex size-28 cursor-pointer flex-col items-center justify-center gap-1.5 overflow-hidden rounded-2xl border border-dashed text-xs transition-colors', - logoDataURL && (light ? 'bg-[#F5F5F5]' : 'bg-[#262626]'), - isDraggingOver && 'border-primary bg-muted/50' - )} - > - {#if logoDataURL} - - {m.upload_logo()} - {:else} - - {m.logo()} - {/if} - - {#if logoDataURL} - - {/if} -
- -
- {@render tabTriggers?.()} - - - - - - - (url = e.currentTarget.value)} - onfocusout={onUrlChange} - aria-invalid={hasUrlError} - type="url" - /> - {#if hasUrlError} -

{m.invalid_url()}

- {/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 2a5c28fd..335349fa 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 @@ -176,7 +176,7 @@ {/snippet} {#snippet LogoCell({ item }: { item: OidcClientWithAllowedGroups })} - {#if item.hasLogo} + {#if item.hasLogo || item.hasDarkLogo} + import FormattedMessage from '#lib/components/formatted-message.svelte'; + import ImageBox from '#lib/components/image-box.svelte'; + import { Button } from '#lib/components/ui/button/index.ts'; + import * as Command from '#lib/components/ui/command/index.ts'; + import * as Popover from '#lib/components/ui/popover/index.ts'; + import { m } from '#lib/paraglide/messages.js'; + import OidcService from '#lib/services/oidc-service.ts'; + import type { AppConfig } from '#lib/types/application-configuration.type.ts'; + import type { OidcClientLogoPreset } from '#lib/types/oidc.type.ts'; + import { debounced } from '#lib/utils/debounce-util.ts'; + import { getAxiosErrorMessage } from '#lib/utils/error-util.ts'; + import { cn } from '#lib/utils/style.ts'; + import { LucideMoon, LucideSun, LucideUpload, LucideX } from '@lucide/svelte'; + import { Command as CommandPrimitive } from 'bits-ui'; + import { mode } from 'mode-watcher'; + + let { + clientName, + logoDataURL, + darkLogoDataURL, + iconLibrary, + onLogoChange, + onPresetSelect, + onReset + }: { + clientName: string; + logoDataURL: string | null; + darkLogoDataURL: string | null; + iconLibrary: AppConfig['iconLibrary']; + onLogoChange: (input: File | string, light: boolean) => void; + onPresetSelect: (preset: OidcClientLogoPreset) => void; + onReset: () => void; + } = $props(); + + const oidcService = new OidcService(); + + const popoverOffset = 4; + const viewportMargin = 16; + const lightBackground = 'bg-[#F5F5F5]'; + const darkBackground = 'bg-[#262626]'; + + let open = $state(false); + let alignOffset = $state(0); + let triggerRef = $state(null); + let contentRef = $state(null); + let lightFileInput = $state(null); + let darkFileInput = $state(null); + let search = $state(''); + let presets = $state([]); + let errorMessage = $state(null); + let latestRequest = 0; + + const isLightMode = $derived(mode.current === 'light'); + const presetsEnabled = $derived(iconLibrary !== 'disabled'); + const imageUrl = $derived(parseImageUrl(search)); + const showPresets = $derived(presetsEnabled && !imageUrl); + const showIconGrid = $derived(showPresets && presets.length > 0); + const effectiveLightLogoURL = $derived(logoDataURL ?? darkLogoDataURL); + const effectiveDarkLogoURL = $derived(darkLogoDataURL ?? logoDataURL); + const previewURL = $derived(isLightMode ? effectiveLightLogoURL : effectiveDarkLogoURL); + const hasLogo = $derived(!!(logoDataURL || darkLogoDataURL)); + + // Input that parses as an absolute HTTP(S) URL is used as the image itself instead of as a search term + function parseImageUrl(value: string): string | null { + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + return url.protocol === 'http:' || url.protocol === 'https:' ? trimmed : null; + } catch { + return null; + } + } + + async function loadPresets(query: string) { + const request = ++latestRequest; + + try { + const result = await oidcService.searchLogoPresets(query); + // Responses can arrive out of order, so only the newest search may update the results + if (request === latestRequest) { + presets = result; + errorMessage = null; + } + } catch (e) { + if (request === latestRequest) { + presets = []; + errorMessage = getAxiosErrorMessage(e); + } + } + } + + // The popover always opens below the logo without collision handling, so it is fitted into the viewport by hand whenever its size or the window changes + $effect(() => { + if (!open || !triggerRef || !contentRef) return; + + const trigger = triggerRef; + const content = contentRef; + const fit = () => fitPopoverIntoViewport(trigger, content); + const observer = new ResizeObserver(fit); + observer.observe(content); + window.addEventListener('resize', fit); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', fit); + }; + }); + + // Measured from the logo because the popover itself is still animating in + function fitPopoverIntoViewport(trigger: HTMLElement, content: HTMLElement) { + const triggerRect = trigger.getBoundingClientRect(); + + // Shift the popover left when it would stick out on the right, which happens on narrow screens + const overflowRight = + triggerRect.left + content.offsetWidth + viewportMargin - window.innerWidth; + alignOffset = -Math.max(0, overflowRight); + + // Scroll the page just far enough to show all of the popover + // The scroll is instant because a smooth one gets cancelled when the results arrive and the first icon is scrolled into view + const overflowBottom = + triggerRect.bottom + + popoverOffset + + content.offsetHeight + + viewportMargin - + window.innerHeight; + if (overflowBottom > 0) { + window.scrollBy({ top: overflowBottom, behavior: 'instant' }); + } + } + + // A search that was queued while typing is skipped once the input has changed since, or if it is a URL + const onSearch = debounced((query: string) => { + if (!presetsEnabled || query !== search || parseImageUrl(query)) return; + return loadPresets(query); + }, 250); + + function resetSearch() { + search = ''; + if (presetsEnabled) { + loadPresets(search); + } + } + + function onOpenChange(isOpen: boolean) { + if (!isOpen) return; + + // Results of the previous search would otherwise show until the new one finishes + presets = []; + errorMessage = null; + resetSearch(); + } + + function selectPreset(preset: OidcClientLogoPreset) { + open = false; + onPresetSelect(preset); + } + + // The picker stays open after a custom image is added, so the current logo tile shows both variants and the other one can be added too + function selectUrl(url: string, light: boolean) { + onLogoChange(url, light); + resetSearch(); + } + + // Pressing enter on a pasted URL uses it as the light logo, which dark mode falls back to + function onInputKeydown(e: KeyboardEvent) { + if (e.key === 'Enter' && imageUrl) { + e.preventDefault(); + selectUrl(imageUrl, true); + } + } + + function onFileChange(e: Event, light: boolean) { + const input = e.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + // Clearing the input lets the same file be picked again after it was removed + input.value = ''; + if (!file) return; + + onLogoChange(file, light); + } + + + + onFileChange(e, true)} +/> + onFileChange(e, false)} +/> + +{#snippet tileHalf(src: string | null, light: boolean)} + + {#if src} + + {:else if light} + + {:else} + + {/if} + +{/snippet} + +{#snippet uploadHalf(light: boolean)} + +{/snippet} + +{#snippet urlHalf(url: string, light: boolean)} + +{/snippet} + +
+ + + {#if previewURL} + + {:else} + + {m.logo()} + {/if} + + + + + onSearch(e.currentTarget.value)} + onkeydown={onInputKeydown} + /> + + +
+ +
+ {#if imageUrl} + {@render urlHalf(imageUrl, true)} + {@render urlHalf(imageUrl, false)} + {:else} + {@render uploadHalf(true)} + {@render uploadHalf(false)} + {/if} +
+ {#if !imageUrl} + + + {#if hasLogo} + (open = false)} + aria-label={m.current_logo()} + title={m.current_logo()} + class="data-selected:ring-primary grid h-14 cursor-pointer grid-cols-2 overflow-hidden rounded-xl outline-hidden select-none data-selected:ring-2" + > + {@render tileHalf(effectiveLightLogoURL, true)} + {@render tileHalf(effectiveDarkLogoURL, false)} + + {/if} + {#if presetsEnabled} + {#each presets as preset (preset.reference)} + selectPreset(preset)} + aria-label={preset.name} + title={preset.name} + class="data-selected:ring-primary grid h-14 cursor-pointer grid-cols-2 overflow-hidden rounded-xl outline-hidden select-none data-selected:ring-2" + > + {@render tileHalf(preset.logoUrl, true)} + {@render tileHalf(preset.darkLogoUrl ?? preset.logoUrl, false)} + + {/each} + {/if} + {/if} +
+ {#if showPresets && presets.length === 0} +
+ {#if errorMessage} + {errorMessage} + {/if} +
+ {/if} +
+
+ + {#if iconLibrary === 'default' && showIconGrid} +

+ +

+ {/if} +
+
+ {#if hasLogo} + + {/if} +
diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/oidc-client-selection.svelte b/frontend/src/routes/settings/admin/user-groups/[id]/oidc-client-selection.svelte index 841a0ef0..08f3febb 100644 --- a/frontend/src/routes/settings/admin/user-groups/[id]/oidc-client-selection.svelte +++ b/frontend/src/routes/settings/admin/user-groups/[id]/oidc-client-selection.svelte @@ -72,7 +72,7 @@ {#snippet LogoCell({ item }: { item: OidcClient })} - {#if item.hasLogo} + {#if item.hasLogo || item.hasDarkLogo}
- {#if client.hasLogo} + {#if client.hasLogo || client.hasDarkLogo} await cleanupBackend()); +// ICON_LIBRARY_URL is fixed when the backend starts, so other states are simulated in the public configuration +async function stubIconLibrary(page: Page, iconLibrary: 'custom' | 'disabled') { + await page.route('**/api/application-configuration', async (route) => { + const response = await route.fetch(); + const config = (await response.json()) as { key: string; value: string }[]; + await route.fulfill({ + response, + json: config.map((entry) => + entry.key === 'iconLibrary' ? { ...entry, value: iconLibrary } : entry + ) + }); + }); +} + test.describe('Create OIDC client', () => { async function createClientTest(page: Page, clientId?: string) { const oidcClient = oidcClients.pingvinShare; @@ -72,9 +86,7 @@ test('Edit OIDC client', async ({ page }) => { await page.getByLabel('Name').fill('Nextcloud updated'); await page.getByLabel('Description').fill('Updated description'); await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback'); - await page.locator('[role="tab"][data-value="light-logo"]').first().click(); await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png'); - await page.locator('[role="tab"][data-value="dark-logo"]').first().click(); await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png'); await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL); await saveUnsavedChanges(page); @@ -84,6 +96,273 @@ test('Edit OIDC client', async ({ page }) => { .then((res) => expect.soft(res.status()).toBe(200)); }); +test('Upload OIDC client logo from the logo picker', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: [] })); + await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); + + const uploadLogo = async (variant: 'light' | 'dark') => { + const fileChooser = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: `Upload ${variant} logo` }).click(); + await (await fileChooser).setFiles('resources/images/cloud-logo.png'); + }; + + // The picker stays open and pre-selects the current logo tile, which shows each upload in its theme + await page.getByRole('button', { name: 'Choose logo' }).click(); + await uploadLogo('dark'); + const customTile = page.getByRole('option', { name: 'Current logo' }); + const customTileImages = customTile.locator('img'); + await expect(customTile).toHaveAttribute('aria-selected', 'true'); + await expect(customTileImages.nth(1)).toHaveAttribute('src', /^blob:/); + await expect(customTileImages.nth(0)).not.toHaveAttribute('src', /^blob:/); + + await uploadLogo('light'); + await expect(customTileImages.nth(0)).toHaveAttribute('src', /^blob:/); + + // The form only previews the logo of the current theme + await page.keyboard.press('Escape'); + const preview = page.getByRole('img', { name: 'Nextcloud logo' }); + await expect(preview).toHaveCount(1); + await expect(preview).toHaveAttribute('src', /^blob:/); + + await saveUnsavedChanges(page); + for (const light of [true, false]) { + await page.request + .get(`/api/oidc/clients/${oidcClient.id}/logo?light=${light}`) + .then((res) => expect.soft(res.status()).toBe(200)); + } +}); + +test('A single OIDC client logo shows in both themes', async ({ page }) => { + const oidcClient = oidcClients.immich; + await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: [] })); + await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); + + // Upload only a dark logo while the page is in light mode + await page.getByRole('button', { name: 'Choose logo' }).click(); + const fileChooser = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Upload dark logo' }).click(); + await (await fileChooser).setFiles('resources/images/cloud-logo.png'); + + // The light half of the current logo tile and the light preview fall back to the dark logo + const currentTileImages = page.getByRole('option', { name: 'Current logo' }).locator('img'); + await expect(currentTileImages.nth(0)).toHaveAttribute('src', /^blob:/); + await expect(currentTileImages.nth(1)).toHaveAttribute('src', /^blob:/); + await page.keyboard.press('Escape'); + await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toHaveAttribute( + 'src', + /^blob:/ + ); + + // After saving, the light logo URL serves the dark logo and the client list shows it + await saveUnsavedChanges(page); + const response = await page.request.get(`/api/oidc/clients/${oidcClient.id}/logo?light=true`); + expect(response.status()).toBe(200); + + await page.goto('/settings/admin/oidc-clients'); + await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible(); +}); + +test('Pick OIDC client logo from the icon library', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const cdnBaseUrl = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main'; + const nextcloud = { + name: 'Nextcloud', + reference: 'nextcloud', + logoUrl: `${cdnBaseUrl}/svg/nextcloud.svg`, + darkLogoUrl: `${cdnBaseUrl}/svg/nextcloud-light.svg` + }; + const otherApp = { + name: '2FAuth', + reference: '2fauth', + logoUrl: `${cdnBaseUrl}/svg/2fauth.svg`, + darkLogoUrl: null + }; + + // Stub the icon index and the CDN so the test doesn't depend on jsDelivr + // Nextcloud is only found by searching for it, like most icons that aren't at the start of the alphabet + const searchTerms: (string | null)[] = []; + await page.route('**/api/oidc/logo-presets*', async (route) => { + const search = new URL(route.request().url()).searchParams.get('search'); + searchTerms.push(search); + await route.fulfill({ json: search?.startsWith('next') ? [nextcloud] : [otherApp] }); + }); + await page.route(`${cdnBaseUrl}/**`, (route) => + route.fulfill({ + contentType: 'image/svg+xml', + body: '' + }) + ); + + // Answer the save with the stored client so the backend doesn't download the icons + await page.route(`**/api/oidc/clients/${oidcClient.id}`, async (route) => { + if (route.request().method() !== 'PUT') return route.fallback(); + await route.fulfill({ response: await route.fetch({ method: 'GET' }) }); + }); + + await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); + await page.getByRole('button', { name: 'Choose logo' }).click(); + + // The search starts empty and lists the icon library + const searchInput = page.getByRole('combobox', { name: 'Search icons or paste an image URL' }); + await expect(searchInput).toHaveValue(''); + await expect(page.getByRole('option', { name: otherApp.name })).toBeVisible(); + expect(searchTerms).toEqual(['']); + await expect(page.getByText('Icons by selfh.st')).toBeVisible(); + + await searchInput.fill('nextcloud'); + await page.getByRole('option', { name: nextcloud.name, exact: true }).click(); + await expect(page.getByRole('img', { name: 'Nextcloud logo' })).toHaveAttribute( + 'src', + nextcloud.logoUrl + ); + + // Before saving, the reopened picker shows the picked icon as the selected current logo even though the search no longer finds it + await page.getByRole('button', { name: 'Choose logo' }).click(); + await expect(page.getByRole('option', { name: otherApp.name })).toBeVisible(); + const currentTile = page.getByRole('option', { name: 'Current logo' }); + await expect(currentTile).toHaveAttribute('aria-selected', 'true'); + await expect(currentTile.locator('img').nth(0)).toHaveAttribute('src', nextcloud.logoUrl); + await expect(currentTile.locator('img').nth(1)).toHaveAttribute('src', nextcloud.darkLogoUrl); + await page.keyboard.press('Escape'); + + const updateRequest = page.waitForRequest( + (req) => req.method() === 'PUT' && req.url().endsWith(`/api/oidc/clients/${oidcClient.id}`) + ); + await saveUnsavedChanges(page); + + const body = (await updateRequest).postDataJSON(); + expect(body.logoUrl).toBe(nextcloud.logoUrl); + expect(body.darkLogoUrl).toBe(nextcloud.darkLogoUrl); +}); + +test('Paste an image URL into the OIDC client logo picker', async ({ page }) => { + const oidcClient = oidcClients.nextcloud; + const darkLogoUrl = 'https://example.com/nextcloud-dark.svg'; + + // Fail the test if a pasted URL is ever sent as an icon search + const searchTerms: (string | null)[] = []; + await page.route('**/api/oidc/logo-presets*', async (route) => { + searchTerms.push(new URL(route.request().url()).searchParams.get('search')); + await route.fulfill({ json: [] }); + }); + await page.route(darkLogoUrl, (route) => + route.fulfill({ + contentType: 'image/svg+xml', + body: '' + }) + ); + + // Answer the save with the stored client so the backend doesn't download the image + await page.route(`**/api/oidc/clients/${oidcClient.id}`, async (route) => { + if (route.request().method() !== 'PUT') return route.fallback(); + await route.fulfill({ response: await route.fetch({ method: 'GET' }) }); + }); + + await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); + + // Each half of the URL tile replaces only its own variant + await page.getByRole('button', { name: 'Choose logo' }).click(); + await page + .getByRole('combobox', { name: 'Search icons or paste an image URL' }) + .fill(darkLogoUrl); + await page.getByRole('button', { name: 'Use as dark logo' }).click(); + + // The search resets and the current logo tile shows the URL in the dark half + await expect( + page.getByRole('combobox', { name: 'Search icons or paste an image URL' }) + ).toHaveValue(''); + await expect( + page.getByRole('option', { name: 'Current logo' }).locator('img').nth(1) + ).toHaveAttribute('src', darkLogoUrl); + await page.keyboard.press('Escape'); + + const updateRequest = page.waitForRequest( + (req) => req.method() === 'PUT' && req.url().endsWith(`/api/oidc/clients/${oidcClient.id}`) + ); + await saveUnsavedChanges(page); + + const body = (await updateRequest).postDataJSON(); + expect(body.darkLogoUrl).toBe(darkLogoUrl); + expect(body.logoUrl).toBeFalsy(); + expect(searchTerms).not.toContain(darkLogoUrl); +}); + +test('OIDC client logo picker stays fully visible when reopened', async ({ page }) => { + const presets = Array.from({ length: 30 }, (_, i) => ({ + name: `App ${i}`, + reference: `app-${i}`, + logoUrl: `https://example.com/app-${i}.svg`, + darkLogoUrl: null + })); + await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: presets })); + + // A short viewport leaves too little room below the button for the picker + const viewportHeight = 760; + await page.setViewportSize({ width: 1100, height: viewportHeight }); + await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`); + + const button = page.getByRole('button', { name: 'Choose logo' }); + const picker = page.locator('[data-slot="popover-content"]'); + const expectPickerInViewport = async () => { + await expect(page.getByRole('option', { name: 'App 0' })).toBeVisible(); + await expect(async () => { + const box = await picker.boundingBox(); + expect(box!.y).toBeGreaterThanOrEqual(0); + expect(box!.y + box!.height).toBeLessThanOrEqual(viewportHeight); + }).toPass(); + }; + + await button.click(); + await expectPickerInViewport(); + + // Reopening with results still in state used to scroll the page towards the unpositioned popover + await page.getByRole('combobox', { name: 'Search icons or paste an image URL' }).fill(''); + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + await button.click(); + await expectPickerInViewport(); +}); + +test('Custom icon library hides the selfh.st credit', async ({ page }) => { + await stubIconLibrary(page, 'custom'); + + // The credit is only shown next to icons, so the custom library has to return one + const preset = { + name: 'Custom App', + reference: 'custom-app', + logoUrl: 'https://example.com/custom-app.svg', + darkLogoUrl: null + }; + await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: [preset] })); + + await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`); + await page.getByRole('button', { name: 'Choose logo' }).click(); + + await expect(page.getByRole('option', { name: preset.name })).toBeVisible(); + await expect(page.getByText('Icons by selfh.st')).toHaveCount(0); +}); + +test('Disabled icon library only accepts image URLs', async ({ page }) => { + await stubIconLibrary(page, 'disabled'); + + // Fail the test if the picker searches the disabled icon library + let searched = false; + await page.route('**/api/oidc/logo-presets*', async (route) => { + searched = true; + await route.fulfill({ json: [] }); + }); + + await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`); + await page.getByRole('button', { name: 'Choose logo' }).click(); + await page + .getByRole('combobox', { name: 'Paste an image URL' }) + .fill('https://example.com/logo.svg'); + await expect(page.getByRole('button', { name: 'Use as light logo' })).toBeVisible(); + await expect(page.getByText('Icons by selfh.st')).toHaveCount(0); + expect(searched).toBe(false); +}); + test('Displays OIDC client endpoints from discovery configuration', async ({ page }) => { const oidcConfiguration = { issuer: 'https://id.example.com',