mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-06 09:49:04 +02:00
feat: add ability to search and select icons from selfh.st (#1807)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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), "")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user