feat: add ability to search and select icons from selfh.st (#1807)

This commit is contained in:
Elias Schneider
2026-10-04 20:40:41 +02:00
committed by GitHub
parent e7d77d8a9d
commit 1be9ba9991
30 changed files with 1506 additions and 247 deletions
@@ -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",
+2
View File
@@ -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"
)
@@ -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(),
@@ -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
}
+59
View File
@@ -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":
@@ -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))
})
}
@@ -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
+8
View File
@@ -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"`
}
+36
View File
@@ -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
}
+36
View File
@@ -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))
}
+224
View File
@@ -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), "")
}
+180
View File
@@ -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))
}
+24 -17
View File
@@ -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)
@@ -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, `<svg xmlns="http://www.w3.org/2000/svg"/>`)
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)
}
})
}
+9 -16
View File
@@ -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",
+10 -2
View File
@@ -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<OidcClientLogoPreset[]>('/oidc/logo-presets', {
params: { search }
});
return data;
};
listClientSecrets = async (id: string) =>
@@ -14,6 +14,7 @@ export type AppConfig = {
accentColor: string;
requireUserEmail: boolean;
tracingEnabled: boolean;
iconLibrary: 'default' | 'custom' | 'disabled';
};
export type AllAppConfig = AppConfig & {
+7
View File
@@ -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[];
+14 -15
View File
@@ -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) {
@@ -122,7 +122,7 @@
></div>
<div class="client relative size-10" class:faded={result}>
{#if client?.hasLogo}
{#if client?.hasLogo || client?.hasDarkLogo}
<img
class="aspect-square size-10 object-contain"
src={cachedOidcClientLogo.getUrl(client.id, isLightMode)}
@@ -105,7 +105,11 @@
{#snippet ClientCell({ item }: { item: ClientRow })}
<div class="flex items-center gap-3">
<OidcClientAvatar id={item.client.id} name={item.client.name} hasLogo={item.client.hasLogo} />
<OidcClientAvatar
id={item.client.id}
name={item.client.name}
hasLogo={item.client.hasLogo || item.client.hasDarkLogo}
/>
<div class="flex flex-col gap-0.5">
<a
class="font-medium hover:underline"
@@ -38,7 +38,7 @@
</script>
{#snippet LogoCell({ item }: { item: ApiClient })}
<OidcClientAvatar id={item.id} name={item.name} hasLogo={item.hasLogo} />
<OidcClientAvatar id={item.id} name={item.name} hasLogo={item.hasLogo || item.hasDarkLogo} />
{/snippet}
<Dialog.Root bind:open>
@@ -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
@@ -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()}
<Tabs.List class="grid h-8 grid-cols-2">
<Tabs.Trigger value="light-logo" class="px-2.5" aria-label={m.light()}>
<LucideSun class="size-3.5" />
</Tabs.Trigger>
<Tabs.Trigger value="dark-logo" class="px-2.5" aria-label={m.dark()}>
<LucideMoon class="size-3.5" />
</Tabs.Trigger>
</Tabs.List>
{/snippet}
{#snippet logoInput()}
<Tabs.Root value="light-logo">
<Tabs.Content value="light-logo">
<OidcClientImageInput
{logoDataURL}
resetLogo={() => resetLogo(true)}
clientName={$inputs.name.value}
light={true}
onLogoChange={(input) => onLogoChange(input, true)}
tabTriggers={logoTabTriggers}
/>
</Tabs.Content>
<Tabs.Content value="dark-logo">
<OidcClientImageInput
light={false}
logoDataURL={darkLogoDataURL}
resetLogo={() => resetLogo(false)}
clientName={$inputs.name.value}
onLogoChange={(input) => onLogoChange(input, false)}
tabTriggers={logoTabTriggers}
/>
</Tabs.Content>
</Tabs.Root>
<OidcClientLogoPicker
clientName={$inputs.name.value}
{logoDataURL}
{darkLogoDataURL}
iconLibrary={$appConfigStore.iconLibrary}
{onLogoChange}
onPresetSelect={selectPreset}
onReset={() => {
resetLogo(true);
resetLogo(false);
}}
/>
{/snippet}
<!-- The form is split into cards so that related settings are grouped together -->
@@ -1,147 +0,0 @@
<script lang="ts">
import FileInput from '#lib/components/form/file-input.svelte';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import ImageBox from '#lib/components/image-box.svelte';
import { Button, buttonVariants } from '#lib/components/ui/button/index.ts';
import { Input } from '#lib/components/ui/input/index.ts';
import { Label } from '#lib/components/ui/label/index.ts';
import * as Popover from '#lib/components/ui/popover/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { cn } from '#lib/utils/style.ts';
import { LucideLink, LucideUpload, LucideX } from '@lucide/svelte';
import type { Snippet } from 'svelte';
let {
logoDataURL,
clientName,
resetLogo,
onLogoChange,
light,
tabTriggers
}: {
logoDataURL: string | null;
clientName: string;
resetLogo: () => void;
onLogoChange: (file: File | string | null) => void;
tabTriggers?: Snippet;
light: boolean;
} = $props();
const id = `oidc-client-logo-${light ? 'light' : 'dark'}`;
let url = $state('');
let hasUrlError = $state(false);
let isDraggingOver = $state(false);
function selectFile(file: File | null) {
url = '';
hasUrlError = false;
onLogoChange(file);
}
function onFileChange(e: Event) {
selectFile((e.target as HTMLInputElement).files?.[0] || null);
}
// Dropping an image on the preview behaves like picking it in the file dialog
function onDrop(e: DragEvent) {
e.preventDefault();
isDraggingOver = false;
const file = e.dataTransfer?.files[0];
if (file?.type.startsWith('image/')) {
selectFile(file);
}
}
function onUrlChange(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (!value) return;
try {
new URL(value);
hasUrlError = false;
} catch {
hasUrlError = true;
return;
}
onLogoChange(value);
}
</script>
<div class="flex w-28 flex-col gap-2">
<div
class="relative"
role="group"
aria-label={m.logo()}
ondragover={(e) => {
e.preventDefault();
isDraggingOver = true;
}}
ondragleave={() => (isDraggingOver = false)}
ondrop={onDrop}
>
<FileInput
{id}
accept="image/*"
onchange={onFileChange}
onclick={(e: any) => (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}
<ImageBox
class="size-full bg-transparent"
src={logoDataURL}
alt={m.name_logo({ name: clientName })}
/>
<span class="sr-only">{m.upload_logo()}</span>
{:else}
<LucideUpload class="size-5" />
{m.logo()}
{/if}
</FileInput>
{#if logoDataURL}
<Button
size="icon"
onclick={resetLogo}
aria-label={m.remove_logo()}
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
>
<LucideX class="size-3" />
</Button>
{/if}
</div>
<div class="flex items-center justify-between gap-1">
{@render tabTriggers?.()}
<Popover.Root>
<Popover.Trigger
class={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'text-muted-foreground')}
aria-label={m.use_image_url()}
>
<LucideLink class="size-4" />
</Popover.Trigger>
<Popover.Content class="w-80">
<Label for="{id}-url" class="text-xs">URL</Label>
<Input
id="{id}-url"
value={url}
oninput={(e) => (url = e.currentTarget.value)}
onfocusout={onUrlChange}
aria-invalid={hasUrlError}
type="url"
/>
{#if hasUrlError}
<p class="text-destructive mt-1 text-start text-xs">{m.invalid_url()}</p>
{/if}
<p class="text-muted-foreground mt-2 text-xs">
<FormattedMessage message={m.logo_from_url_description} />
</p>
</Popover.Content>
</Popover.Root>
</div>
</div>
@@ -176,7 +176,7 @@
{/snippet}
{#snippet LogoCell({ item }: { item: OidcClientWithAllowedGroups })}
{#if item.hasLogo}
{#if item.hasLogo || item.hasDarkLogo}
<ImageBox
class="size-12 rounded-lg"
src={cachedOidcClientLogo.getUrl(item.id, isLightMode)}
@@ -0,0 +1,356 @@
<script lang="ts">
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<HTMLElement | null>(null);
let contentRef = $state<HTMLElement | null>(null);
let lightFileInput = $state<HTMLInputElement | null>(null);
let darkFileInput = $state<HTMLInputElement | null>(null);
let search = $state('');
let presets = $state<OidcClientLogoPreset[]>([]);
let errorMessage = $state<string | null>(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);
}
</script>
<!-- The file inputs live outside the popover so a picked file is still handled after it closes -->
<input
id="oidc-client-logo-light"
bind:this={lightFileInput}
type="file"
accept="image/*"
class="hidden"
onchange={(e) => onFileChange(e, true)}
/>
<input
id="oidc-client-logo-dark"
bind:this={darkFileInput}
type="file"
accept="image/*"
class="hidden"
onchange={(e) => onFileChange(e, false)}
/>
{#snippet tileHalf(src: string | null, light: boolean)}
<span
class={cn('flex items-center justify-center p-2', light ? lightBackground : darkBackground)}
>
{#if src}
<img {src} alt="" class="size-7 object-contain" loading="lazy" />
{:else if light}
<LucideSun class="size-4 text-neutral-400" />
{:else}
<LucideMoon class="size-4 text-neutral-500" />
{/if}
</span>
{/snippet}
{#snippet uploadHalf(light: boolean)}
<button
type="button"
class={cn(
'flex flex-col items-center justify-center gap-1 text-xs font-medium transition-opacity hover:opacity-80',
light ? `${lightBackground} text-neutral-600` : `${darkBackground} text-neutral-300`
)}
aria-label={light ? m.upload_light_logo() : m.upload_dark_logo()}
onclick={() => (light ? lightFileInput : darkFileInput)?.click()}
>
<LucideUpload class="size-4" />
{light ? m.light() : m.dark()}
</button>
{/snippet}
{#snippet urlHalf(url: string, light: boolean)}
<button
type="button"
class={cn(
'flex flex-col items-center justify-center gap-1 text-[10px] font-medium transition-opacity hover:opacity-80',
light ? `${lightBackground} text-neutral-600` : `${darkBackground} text-neutral-300`
)}
aria-label={light ? m.use_as_light_logo() : m.use_as_dark_logo()}
title={light ? m.use_as_light_logo() : m.use_as_dark_logo()}
onclick={() => selectUrl(url, light)}
>
<img src={url} alt="" class="size-6 object-contain" />
{light ? m.light() : m.dark()}
</button>
{/snippet}
<div class="relative w-28">
<Popover.Root bind:open {onOpenChange}>
<Popover.Trigger
bind:ref={triggerRef}
aria-label={m.choose_logo()}
class={cn(
'text-muted-foreground flex size-28 cursor-pointer flex-col items-center justify-center gap-1.5 overflow-hidden rounded-2xl border text-xs transition-opacity hover:opacity-90',
previewURL ? (isLightMode ? lightBackground : darkBackground) : 'border-dashed'
)}
>
{#if previewURL}
<ImageBox
class="size-full bg-transparent"
src={previewURL}
alt={m.name_logo({ name: clientName })}
/>
{:else}
<LucideUpload class="size-5" />
{m.logo()}
{/if}
</Popover.Trigger>
<Popover.Content
bind:ref={contentRef}
class="w-[min(24rem,calc(100vw-2rem))] gap-0 overflow-hidden p-0"
align="start"
sideOffset={popoverOffset}
{alignOffset}
avoidCollisions={false}
>
<!-- The popover isn't positioned yet when the first icon gets selected, so scrolling it into view would scroll the page instead -->
<Command.Root shouldFilter={false} columns={3} disableInitialScroll class="rounded-none">
<Command.Input
placeholder={presetsEnabled ? m.search_icons_or_paste_url() : m.paste_image_url()}
aria-label={presetsEnabled ? m.search_icons_or_paste_url() : m.paste_image_url()}
bind:value={search}
oninput={(e) => onSearch(e.currentTarget.value)}
onkeydown={onInputKeydown}
/>
<!-- Without icons the list shrinks to fit the remaining tiles -->
<Command.List class={cn('px-2 pt-4 pb-2', showIconGrid && 'h-80')}>
<div class="grid grid-cols-3 gap-2">
<!-- The first tile adds a custom image, either from a pasted URL or as an upload -->
<div class="grid h-14 grid-cols-2 overflow-hidden rounded-xl">
{#if imageUrl}
{@render urlHalf(imageUrl, true)}
{@render urlHalf(imageUrl, false)}
{:else}
{@render uploadHalf(true)}
{@render uploadHalf(false)}
{/if}
</div>
{#if !imageUrl}
<!-- The current logo gets its own tile, even before it is saved, because a picked icon isn't necessarily in the search results -->
<!-- As the first item of the list it is what the picker selects, so it shows which logo is in use -->
{#if hasLogo}
<CommandPrimitive.Item
value="custom"
onSelect={() => (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)}
</CommandPrimitive.Item>
{/if}
{#if presetsEnabled}
{#each presets as preset (preset.reference)}
<CommandPrimitive.Item
value={preset.reference}
onSelect={() => 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)}
</CommandPrimitive.Item>
{/each}
{/if}
{/if}
</div>
{#if showPresets && presets.length === 0}
<div class="text-muted-foreground flex justify-center px-3 py-6 text-center text-sm">
{#if errorMessage}
{errorMessage}
{/if}
</div>
{/if}
</Command.List>
</Command.Root>
<!-- A custom icon library isn't necessarily selfh.st's collection, so the credit only applies to the default one while its icons are shown -->
{#if iconLibrary === 'default' && showIconGrid}
<p class="text-muted-foreground border-t px-3 py-2 text-[11px]">
<FormattedMessage message={m.icons_provided_by_selfhst} />
</p>
{/if}
</Popover.Content>
</Popover.Root>
{#if hasLogo}
<Button
size="icon"
onclick={onReset}
aria-label={m.remove_logo()}
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
>
<LucideX class="size-3" />
</Button>
{/if}
</div>
@@ -72,7 +72,7 @@
</script>
{#snippet LogoCell({ item }: { item: OidcClient })}
{#if item.hasLogo}
{#if item.hasLogo || item.hasDarkLogo}
<ImageBox
class="size-12 rounded-lg"
src={cachedOidcClientLogo.getUrl(item.id, isLightMode)}
@@ -40,7 +40,7 @@
<Card.Content class="p-0">
<div class="flex gap-3">
<div class="aspect-square h-[56px]">
{#if client.hasLogo}
{#if client.hasLogo || client.hasDarkLogo}
<ImageBox
class="size-14"
src={cachedOidcClientLogo.getUrl(client.id, isLightMode)}
+281 -2
View File
@@ -7,6 +7,20 @@ import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => 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: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>'
})
);
// 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: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>'
})
);
// 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',