diff --git a/authentik/admin/files/backends/static.py b/authentik/admin/files/backends/static.py
index edff6ffa5c..fbd9da1713 100644
--- a/authentik/admin/files/backends/static.py
+++ b/authentik/admin/files/backends/static.py
@@ -3,7 +3,7 @@ from pathlib import Path
from django.http.request import HttpRequest
-from authentik.admin.files.backends.base import Backend
+from authentik.admin.files.backends.base import Backend, THEME_VARIABLE
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
@@ -35,6 +35,20 @@ class StaticBackend(Backend):
for file_path in STATIC_ASSETS_SOURCES_DIR.iterdir():
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
yield f"{STATIC_PATH_PREFIX}/authentik/sources/{file_path.name}"
+ continue
+
+ if not file_path.is_dir():
+ continue
+
+ for extension in STATIC_FILE_EXTENSIONS:
+ light_variant = file_path / f"light{extension}"
+ dark_variant = file_path / f"dark{extension}"
+ if light_variant.exists() and dark_variant.exists():
+ yield (
+ f"{STATIC_PATH_PREFIX}/authentik/sources/"
+ f"{file_path.name}/{THEME_VARIABLE}{extension}"
+ )
+ break
# List other static assets
for dir in STATIC_ASSETS_DIRS:
diff --git a/authentik/admin/files/backends/tests/test_static_backend.py b/authentik/admin/files/backends/tests/test_static_backend.py
index 4bff77d59e..768dd131f9 100644
--- a/authentik/admin/files/backends/tests/test_static_backend.py
+++ b/authentik/admin/files/backends/tests/test_static_backend.py
@@ -37,6 +37,7 @@ class TestStaticBackend(TestCase):
"""Test list_files includes expected files"""
files = list(self.backend.list_files())
+ self.assertIn("/static/authentik/sources/github/%(theme)s.svg", files)
self.assertIn("/static/authentik/sources/ldap.png", files)
self.assertIn("/static/authentik/sources/openidconnect.svg", files)
self.assertIn("/static/authentik/sources/saml.png", files)
diff --git a/authentik/admin/files/tests/test_api.py b/authentik/admin/files/tests/test_api.py
index a7a9673a36..7e9c4f35bd 100644
--- a/authentik/admin/files/tests/test_api.py
+++ b/authentik/admin/files/tests/test_api.py
@@ -219,6 +219,31 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
manager.delete_file(file_name)
+ def test_list_files_includes_themed_urls_for_static_themed_icons(self):
+ """Test listing static themed icons exposes a %(theme)s placeholder entry."""
+ response = self.client.get(
+ reverse(
+ "authentik_api:files",
+ query={
+ "search": "github/%(theme)s.svg",
+ },
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(
+ {
+ "name": "/static/authentik/sources/github/%(theme)s.svg",
+ "url": "http://testserver/static/authentik/sources/github/%(theme)s.svg",
+ "mime_type": "image/svg+xml",
+ "themed_urls": {
+ "light": "http://testserver/static/authentik/sources/github/light.svg",
+ "dark": "http://testserver/static/authentik/sources/github/dark.svg",
+ },
+ },
+ response.data,
+ )
+
def test_list_files_includes_themed_urls_dict(self):
"""Test listing files includes themed_urls as dict for themed files"""
manager = FileManager(FileUsage.MEDIA)
diff --git a/authentik/core/api/object_types.py b/authentik/core/api/object_types.py
index 278c643746..71d124de38 100644
--- a/authentik/core/api/object_types.py
+++ b/authentik/core/api/object_types.py
@@ -9,7 +9,7 @@ from rest_framework.fields import (
from rest_framework.request import Request
from rest_framework.response import Response
-from authentik.core.api.utils import PassiveSerializer
+from authentik.core.api.utils import PassiveSerializer, ThemedUrlsSerializer
from authentik.lib.models import DeprecatedMixin
from authentik.lib.utils.reflection import all_subclasses
@@ -22,7 +22,8 @@ class TypeCreateSerializer(PassiveSerializer):
component = CharField(required=True)
model_name = CharField(required=True)
- icon_url = CharField(required=False)
+ icon_url = CharField(required=False, allow_null=True)
+ icon_themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
requires_enterprise = BooleanField(default=False)
deprecated = BooleanField(default=False)
@@ -66,6 +67,7 @@ class TypesMixin:
"component": instance.component,
"model_name": subclass._meta.model_name,
"icon_url": getattr(instance, "icon_url", None),
+ "icon_themed_urls": getattr(instance, "icon_themed_urls", None),
"requires_enterprise": False,
"deprecated": isinstance(instance, DeprecatedMixin),
}
diff --git a/authentik/core/models.py b/authentik/core/models.py
index 4043baa9b8..13bd91b19e 100644
--- a/authentik/core/models.py
+++ b/authentik/core/models.py
@@ -14,10 +14,12 @@ from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser, Permission
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
+from django.contrib.staticfiles import finders
from django.core.validators import validate_slug
from django.db import models
from django.db.models import Manager, Q, QuerySet, options
from django.http import HttpRequest
+from django.templatetags.static import static
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -58,6 +60,27 @@ USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
_USER_ATTR_PREFIX = f"{USER_PATH_SYSTEM_PREFIX}/user"
USER_ATTRIBUTE_DEBUG = f"{_USER_ATTR_PREFIX}/debug"
USER_ATTRIBUTE_GENERATED = f"{_USER_ATTR_PREFIX}/generated"
+
+
+def _get_default_source_icon_themed_urls(icon_name: str) -> dict[str, str] | None:
+ themed_paths = {
+ "light": f"authentik/sources/{icon_name}/light.svg",
+ "dark": f"authentik/sources/{icon_name}/dark.svg",
+ }
+ if all(finders.find(path) for path in themed_paths.values()):
+ return {theme: static(path) for theme, path in themed_paths.items()}
+
+ for extension in ("svg", "png"):
+ legacy_path = f"authentik/sources/{icon_name}.{extension}"
+ if finders.find(legacy_path):
+ legacy_url = static(legacy_path)
+ return {
+ "light": legacy_url,
+ "dark": legacy_url,
+ }
+ return None
+
+
USER_ATTRIBUTE_EXPIRES = f"{_USER_ATTR_PREFIX}/expires"
USER_ATTRIBUTE_DELETE_ON_LOGOUT = f"{_USER_ATTR_PREFIX}/delete-on-logout"
USER_ATTRIBUTE_SOURCES = f"{_USER_ATTR_PREFIX}/sources"
@@ -951,34 +974,46 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
objects = InheritanceManager()
- def get_icon_url(self, request=None, use_cache: bool = True) -> str | None:
- """Get the URL to the source icon."""
- if not self.icon:
- return None
- return get_file_manager(FileUsage.MEDIA).file_url(self.icon, request, use_cache=use_cache)
+ default_icon_name: str | None = None
+ """Source type name used to resolve built-in themed icons (e.g. "discord")."""
@property
def icon_url(self) -> str | None:
- """Get the URL to the source icon"""
- return self.get_icon_url()
-
- def get_icon_themed_urls(
- self,
- request=None,
- use_cache: bool = True,
- ) -> dict[str, str] | None:
- """Get themed URLs for icon if it contains %(theme)s."""
- if not self.icon:
- return None
- return get_file_manager(FileUsage.MEDIA).themed_urls(
- self.icon,
- request,
- use_cache=use_cache,
- )
+ """Get the URL to the source icon."""
+ manager = get_file_manager(FileUsage.MEDIA)
+ custom_icon = None
+ try:
+ custom_icon = self.icon
+ except AttributeError:
+ # Abstract type instances (created via __new__) don't have field state.
+ custom_icon = None
+ if custom_icon:
+ return manager.file_url(custom_icon)
+ if self.default_icon_name:
+ return _get_default_source_icon_url(self.default_icon_name)
+ return None
@property
def icon_themed_urls(self) -> dict[str, str] | None:
- return self.get_icon_themed_urls()
+ """Get themed URLs for source icon."""
+ manager = get_file_manager(FileUsage.MEDIA)
+ # If the user set a custom icon, check if it supports themes.
+ custom_icon = None
+ try:
+ custom_icon = self.icon
+ except AttributeError:
+ # Abstract type instances (created via __new__) don't have field state.
+ custom_icon = None
+ if custom_icon:
+ return manager.themed_urls(custom_icon)
+ # Fall back to built-in default icons.
+ if self.default_icon_name:
+ return _get_default_source_icon_themed_urls(self.default_icon_name)
+ return None
+
+ @property
+ def icon_dynamic_url(self) -> dict[str, str] | None:
+ return _build_dynamic_url_map(self.icon_url, self.icon_themed_urls)
def get_user_path(self) -> str:
"""Get user path, fallback to default for formatting errors"""
diff --git a/authentik/core/types.py b/authentik/core/types.py
index 61c25fa3e1..2da9d9b223 100644
--- a/authentik/core/types.py
+++ b/authentik/core/types.py
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from rest_framework.fields import CharField
-from authentik.core.api.utils import PassiveSerializer
+from authentik.core.api.utils import PassiveSerializer, ThemedUrlsSerializer
from authentik.flows.challenge import Challenge
@@ -21,6 +21,9 @@ class UILoginButton:
# Icon URL, used as-is
icon_url: str | None = None
+ # Pre-resolved themed icon URLs for light/dark variants
+ icon_themed_urls: dict[str, str] | None = None
+
# Whether this source should be displayed as a prominent button
promoted: bool = False
@@ -32,4 +35,5 @@ class UserSettingSerializer(PassiveSerializer):
component = CharField()
title = CharField(required=True)
configure_url = CharField(required=False)
- icon_url = CharField(required=False)
+ icon_url = CharField(required=False, allow_null=True)
+ icon_themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
diff --git a/authentik/sources/kerberos/models.py b/authentik/sources/kerberos/models.py
index 07ef0d5af6..9e83849c2a 100644
--- a/authentik/sources/kerberos/models.py
+++ b/authentik/sources/kerberos/models.py
@@ -11,7 +11,6 @@ import pglock
from django.db import connection, models
from django.http import HttpRequest
from django.shortcuts import reverse
-from django.templatetags.static import static
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from kadmin import KAdm5Variant, KAdmin, KAdminApiVersion
@@ -129,12 +128,7 @@ class KerberosSource(IncomingSyncSource):
def property_mapping_type(self) -> type[PropertyMapping]:
return KerberosSourcePropertyMapping
- @property
- def icon_url(self) -> str:
- icon = super().icon_url
- if not icon:
- return static("authentik/sources/kerberos.png")
- return icon
+ default_icon_name = "kerberos"
@property
def schedule_specs(self) -> list[ScheduleSpec]:
@@ -168,7 +162,7 @@ class KerberosSource(IncomingSyncSource):
}
),
name=self.name,
- icon_url=self.get_icon_url(request, use_cache=False) or self.icon_url,
+ icon_url=self.icon_dynamic_url,
promoted=self.promoted,
)
@@ -181,7 +175,7 @@ class KerberosSource(IncomingSyncSource):
"authentik_sources_kerberos:spnego-login",
kwargs={"source_slug": self.slug},
),
- "icon_url": self.icon_url,
+ "icon_url": self.icon_dynamic_url,
}
)
diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py
index f7fe889a97..9ab7dc9750 100644
--- a/authentik/sources/ldap/models.py
+++ b/authentik/sources/ldap/models.py
@@ -9,7 +9,6 @@ from typing import Any
import pglock
from django.db import connection, models
-from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls
from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError
@@ -211,9 +210,7 @@ class LDAPSource(IncomingSyncSource):
**kwargs,
)
- @property
- def icon_url(self) -> str:
- return static("authentik/sources/ldap.png")
+ default_icon_name = "ldap"
def server(self, **kwargs) -> ServerPool:
"""Get LDAP Server/ServerPool"""
diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py
index 3e57e0e4d8..56f7cc2195 100644
--- a/authentik/sources/oauth/models.py
+++ b/authentik/sources/oauth/models.py
@@ -15,6 +15,7 @@ from authentik.core.models import (
PropertyMapping,
Source,
UserSourceConnection,
+ _get_default_source_icon_themed_urls,
)
from authentik.core.types import UILoginButton, UserSettingSerializer
@@ -112,23 +113,23 @@ class OAuthSource(NonCreatableType, Source):
return self.source_type().get_base_group_properties(source=self, **kwargs)
@property
- def icon_url(self) -> str | None:
- # When listing source types, this property might be retrieved from an abstract
- # model. In that case we can't check self.provider_type or self.icon_url
- # and as such we attempt to find the correct provider type based on the mode name
- if self.Meta.abstract:
- from authentik.sources.oauth.types.registry import registry
+ def icon_themed_urls(self) -> dict[str, str] | None:
+ """Get themed URLs for source icon.
- provider_type = registry.find_type(
- self._meta.model_name.replace(OAuthSource._meta.model_name, "")
- )
- return provider_type().icon_url()
- icon = super().icon_url
- if not icon:
- provider_type = self.source_type
- provider = provider_type()
- icon = provider.icon_url()
- return icon
+ OAuth source types are abstract models so the DB always stores OAuthSource
+ instances. We resolve the built-in icon from the provider_type field instead
+ of the class-level default_icon_name (which only exists on the abstract
+ subclasses used by the TypeCreate wizard).
+ """
+ urls = super().icon_themed_urls
+ if urls:
+ return urls
+ try:
+ if self.provider_type:
+ return _get_default_source_icon_themed_urls(self.provider_type)
+ except AttributeError:
+ pass
+ return None
def ui_login_button(self, request: HttpRequest) -> UILoginButton:
provider_type = self.source_type
@@ -136,7 +137,7 @@ class OAuthSource(NonCreatableType, Source):
return UILoginButton(
name=self.name,
challenge=provider.login_challenge(self, request),
- icon_url=self.get_icon_url(request, use_cache=False) or self.icon_url,
+ icon_url=self.icon_dynamic_url,
promoted=self.promoted,
)
@@ -150,6 +151,7 @@ class OAuthSource(NonCreatableType, Source):
kwargs={"source_slug": self.slug},
),
"icon_url": self.icon_url,
+ "icon_themed_urls": self.icon_themed_urls,
}
)
@@ -164,6 +166,8 @@ class OAuthSource(NonCreatableType, Source):
class GitHubOAuthSource(CreatableType, OAuthSource):
"""Social Login using GitHub.com or a GitHub-Enterprise Instance."""
+ default_icon_name = "github"
+
class Meta:
abstract = True
verbose_name = _("GitHub OAuth Source")
@@ -173,6 +177,8 @@ class GitHubOAuthSource(CreatableType, OAuthSource):
class GitLabOAuthSource(CreatableType, OAuthSource):
"""Social Login using GitLab.com or a GitLab Instance."""
+ default_icon_name = "gitlab"
+
class Meta:
abstract = True
verbose_name = _("GitLab OAuth Source")
@@ -182,6 +188,8 @@ class GitLabOAuthSource(CreatableType, OAuthSource):
class TwitchOAuthSource(CreatableType, OAuthSource):
"""Social Login using Twitch."""
+ default_icon_name = "twitch"
+
class Meta:
abstract = True
verbose_name = _("Twitch OAuth Source")
@@ -191,6 +199,8 @@ class TwitchOAuthSource(CreatableType, OAuthSource):
class MailcowOAuthSource(CreatableType, OAuthSource):
"""Social Login using Mailcow."""
+ default_icon_name = "mailcow"
+
class Meta:
abstract = True
verbose_name = _("Mailcow OAuth Source")
@@ -200,6 +210,8 @@ class MailcowOAuthSource(CreatableType, OAuthSource):
class TwitterOAuthSource(CreatableType, OAuthSource):
"""Social Login using Twitter.com"""
+ default_icon_name = "twitter"
+
class Meta:
abstract = True
verbose_name = _("Twitter OAuth Source")
@@ -209,6 +221,8 @@ class TwitterOAuthSource(CreatableType, OAuthSource):
class FacebookOAuthSource(CreatableType, OAuthSource):
"""Social Login using Facebook.com."""
+ default_icon_name = "facebook"
+
class Meta:
abstract = True
verbose_name = _("Facebook OAuth Source")
@@ -218,6 +232,8 @@ class FacebookOAuthSource(CreatableType, OAuthSource):
class DiscordOAuthSource(CreatableType, OAuthSource):
"""Social Login using Discord."""
+ default_icon_name = "discord"
+
class Meta:
abstract = True
verbose_name = _("Discord OAuth Source")
@@ -227,6 +243,8 @@ class DiscordOAuthSource(CreatableType, OAuthSource):
class SlackOAuthSource(CreatableType, OAuthSource):
"""Social Login using Slack."""
+ default_icon_name = "slack"
+
class Meta:
abstract = True
verbose_name = _("Slack OAuth Source")
@@ -236,6 +254,8 @@ class SlackOAuthSource(CreatableType, OAuthSource):
class PatreonOAuthSource(CreatableType, OAuthSource):
"""Social Login using Patreon."""
+ default_icon_name = "patreon"
+
class Meta:
abstract = True
verbose_name = _("Patreon OAuth Source")
@@ -245,6 +265,8 @@ class PatreonOAuthSource(CreatableType, OAuthSource):
class GoogleOAuthSource(CreatableType, OAuthSource):
"""Social Login using Google or Google Workspace (GSuite)."""
+ default_icon_name = "google"
+
class Meta:
abstract = True
verbose_name = _("Google OAuth Source")
@@ -254,6 +276,8 @@ class GoogleOAuthSource(CreatableType, OAuthSource):
class AzureADOAuthSource(CreatableType, OAuthSource):
"""(Deprecated) Social Login using Azure AD."""
+ default_icon_name = "azuread"
+
class Meta:
abstract = True
verbose_name = _("Azure AD OAuth Source")
@@ -265,6 +289,8 @@ class AzureADOAuthSource(CreatableType, OAuthSource):
class EntraIDOAuthSource(CreatableType, OAuthSource):
"""Social Login using Entra ID."""
+ default_icon_name = "entraid"
+
class Meta:
abstract = True
verbose_name = _("Entra ID OAuth Source")
@@ -274,6 +300,8 @@ class EntraIDOAuthSource(CreatableType, OAuthSource):
class OpenIDConnectOAuthSource(CreatableType, OAuthSource):
"""Login using a Generic OpenID-Connect compliant provider."""
+ default_icon_name = "openidconnect"
+
class Meta:
abstract = True
verbose_name = _("OpenID OAuth Source")
@@ -283,6 +311,8 @@ class OpenIDConnectOAuthSource(CreatableType, OAuthSource):
class AppleOAuthSource(CreatableType, OAuthSource):
"""Social Login using Apple."""
+ default_icon_name = "apple"
+
class Meta:
abstract = True
verbose_name = _("Apple OAuth Source")
@@ -292,6 +322,8 @@ class AppleOAuthSource(CreatableType, OAuthSource):
class OktaOAuthSource(CreatableType, OAuthSource):
"""Social Login using Okta."""
+ default_icon_name = "okta"
+
class Meta:
abstract = True
verbose_name = _("Okta OAuth Source")
@@ -301,6 +333,8 @@ class OktaOAuthSource(CreatableType, OAuthSource):
class RedditOAuthSource(CreatableType, OAuthSource):
"""Social Login using reddit.com."""
+ default_icon_name = "reddit"
+
class Meta:
abstract = True
verbose_name = _("Reddit OAuth Source")
@@ -310,6 +344,8 @@ class RedditOAuthSource(CreatableType, OAuthSource):
class WeChatOAuthSource(CreatableType, OAuthSource):
"""Social Login using WeChat."""
+ default_icon_name = "wechat"
+
class Meta:
abstract = True
verbose_name = _("WeChat OAuth Source")
diff --git a/authentik/sources/oauth/types/registry.py b/authentik/sources/oauth/types/registry.py
index d501ffb2e1..c47143bb57 100644
--- a/authentik/sources/oauth/types/registry.py
+++ b/authentik/sources/oauth/types/registry.py
@@ -5,7 +5,6 @@ from enum import Enum
from typing import Any
from django.http.request import HttpRequest
-from django.templatetags.static import static
from django.urls.base import reverse
from structlog.stdlib import get_logger
@@ -46,10 +45,6 @@ class SourceType:
AuthorizationCodeAuthMethod.BASIC_AUTH
)
- def icon_url(self) -> str:
- """Get Icon URL for login"""
- return static(f"authentik/sources/{self.name}.svg")
-
def login_challenge(self, source: OAuthSource, request: HttpRequest) -> Challenge:
"""Allow types to return custom challenges"""
return RedirectChallenge(
diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py
index 660ffff3f6..d156d64a5f 100644
--- a/authentik/sources/plex/models.py
+++ b/authentik/sources/plex/models.py
@@ -5,7 +5,6 @@ from typing import Any
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.http.request import HttpRequest
-from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from rest_framework.fields import CharField
from rest_framework.serializers import BaseSerializer, Serializer
@@ -100,12 +99,7 @@ class PlexSource(ScheduledModel, Source):
"name": group_id,
}
- @property
- def icon_url(self) -> str:
- icon = super().icon_url
- if not icon:
- icon = static("authentik/sources/plex.svg")
- return icon
+ default_icon_name = "plex"
def ui_login_button(self, request: HttpRequest) -> UILoginButton:
return UILoginButton(
@@ -116,7 +110,7 @@ class PlexSource(ScheduledModel, Source):
"slug": self.slug,
}
),
- icon_url=self.get_icon_url(request, use_cache=False) or self.icon_url,
+ icon_url=self.icon_dynamic_url,
name=self.name,
promoted=self.promoted,
)
@@ -127,7 +121,7 @@ class PlexSource(ScheduledModel, Source):
"title": self.name,
"component": "ak-user-settings-source-plex",
"configure_url": self.client_id,
- "icon_url": self.icon_url,
+ "icon_url": self.icon_dynamic_url,
}
)
diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py
index 614b7d1069..86f4c6cd6c 100644
--- a/authentik/sources/saml/models.py
+++ b/authentik/sources/saml/models.py
@@ -4,7 +4,6 @@ from typing import Any
from django.db import models
from django.http import HttpRequest
-from django.templatetags.static import static
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from lxml.etree import _Element # nosec
@@ -266,12 +265,7 @@ class SAMLSource(Source):
reverse(f"authentik_sources_saml:{view}", kwargs={"source_slug": self.slug})
)
- @property
- def icon_url(self) -> str:
- icon = super().icon_url
- if not icon:
- return static("authentik/sources/saml.png")
- return icon
+ default_icon_name = "saml"
def ui_login_button(self, request: HttpRequest) -> UILoginButton:
return UILoginButton(
@@ -284,7 +278,7 @@ class SAMLSource(Source):
}
),
name=self.name,
- icon_url=self.get_icon_url(request, use_cache=False) or self.icon_url,
+ icon_url=self.icon_dynamic_url,
promoted=self.promoted,
)
@@ -298,6 +292,7 @@ class SAMLSource(Source):
kwargs={"source_slug": self.slug},
),
"icon_url": self.icon_url,
+ "icon_themed_urls": self.icon_themed_urls,
}
)
diff --git a/authentik/sources/scim/models.py b/authentik/sources/scim/models.py
index db57185443..49269dc975 100644
--- a/authentik/sources/scim/models.py
+++ b/authentik/sources/scim/models.py
@@ -4,7 +4,6 @@ from typing import Any
from uuid import uuid4
from django.db import models
-from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import BaseSerializer, Serializer
@@ -27,9 +26,7 @@ class SCIMSource(Source):
"""Return component used to edit this object"""
return "ak-source-scim-form"
- @property
- def icon_url(self) -> str:
- return static("authentik/sources/scim.png")
+ default_icon_name = "scim"
@property
def serializer(self) -> BaseSerializer:
diff --git a/authentik/sources/telegram/models.py b/authentik/sources/telegram/models.py
index f7ad5c7247..f36bd51212 100644
--- a/authentik/sources/telegram/models.py
+++ b/authentik/sources/telegram/models.py
@@ -5,7 +5,6 @@ from urllib.parse import urlencode
from django.db import models
from django.http import HttpRequest
-from django.templatetags.static import static
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import BaseSerializer, Serializer
@@ -42,12 +41,7 @@ class TelegramSource(Source):
def component(self) -> str:
return "ak-source-telegram-form"
- @property
- def icon_url(self) -> str | None:
- icon = super().icon_url
- if not icon:
- icon = static("authentik/sources/telegram.svg")
- return icon
+ default_icon_name = "telegram"
@property
def serializer(self) -> type[BaseSerializer]:
@@ -66,7 +60,7 @@ class TelegramSource(Source):
}
),
name=self.name,
- icon_url=self.get_icon_url(request, use_cache=False) or self.icon_url,
+ icon_url=self.icon_dynamic_url,
promoted=self.promoted,
)
@@ -75,7 +69,7 @@ class TelegramSource(Source):
data={
"title": self.name,
"component": "ak-user-settings-source-telegram",
- "icon_url": self.icon_url,
+ "icon_url": self.icon_dynamic_url,
"configure_url": urlencode(
{
"bot_username": self.bot_username,
diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py
index 28780d278c..5df5039d0c 100644
--- a/authentik/stages/identification/stage.py
+++ b/authentik/stages/identification/stage.py
@@ -14,7 +14,7 @@ from rest_framework.fields import BooleanField, CharField, ChoiceField, DictFiel
from rest_framework.serializers import ValidationError
from sentry_sdk import start_span
-from authentik.core.api.utils import JSONDictField, PassiveSerializer
+from authentik.core.api.utils import JSONDictField, PassiveSerializer, ThemedUrlsSerializer
from authentik.core.models import Application, Source, User
from authentik.endpoints.connectors.agent.stage import PLAN_CONTEXT_DEVICE_AUTH_TOKEN
from authentik.endpoints.models import Device
@@ -85,6 +85,7 @@ class LoginSourceSerializer(PassiveSerializer):
name = CharField()
icon_url = CharField(required=False, allow_null=True)
+ icon_themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
promoted = BooleanField(default=False)
challenge = ChallengeDictWrapper()
diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py
index 18096a1a50..e93e58df67 100644
--- a/authentik/stages/identification/tests.py
+++ b/authentik/stages/identification/tests.py
@@ -206,7 +206,8 @@ class TestIdentificationStage(FlowTestCase):
"component": "xak-flow-redirect",
"to": f"/source/oauth/login/{self.source.slug}/",
},
- "icon_url": "/static/authentik/sources/default.svg",
+ "icon_url": None,
+ "icon_themed_urls": None,
"name": self.source.name,
"promoted": False,
}
@@ -242,7 +243,8 @@ class TestIdentificationStage(FlowTestCase):
"component": "xak-flow-redirect",
"to": f"/source/oauth/login/{self.source.slug}/",
},
- "icon_url": "/static/authentik/sources/default.svg",
+ "icon_url": None,
+ "icon_themed_urls": None,
"name": self.source.name,
"promoted": False,
}
@@ -317,7 +319,8 @@ class TestIdentificationStage(FlowTestCase):
"component": "xak-flow-redirect",
"to": f"/source/oauth/login/{self.source.slug}/",
},
- "icon_url": "/static/authentik/sources/default.svg",
+ "icon_url": None,
+ "icon_themed_urls": None,
"name": self.source.name,
"promoted": False,
}
@@ -373,7 +376,8 @@ class TestIdentificationStage(FlowTestCase):
"component": "xak-flow-redirect",
"to": f"/source/oauth/login/{self.source.slug}/",
},
- "icon_url": "/static/authentik/sources/default.svg",
+ "icon_url": None,
+ "icon_themed_urls": None,
"name": self.source.name,
"promoted": False,
}
@@ -436,7 +440,8 @@ class TestIdentificationStage(FlowTestCase):
"component": "xak-flow-redirect",
"to": f"/source/oauth/login/{self.source.slug}/",
},
- "icon_url": "/static/authentik/sources/default.svg",
+ "icon_url": None,
+ "icon_themed_urls": None,
"name": self.source.name,
"promoted": False,
}
@@ -481,7 +486,8 @@ class TestIdentificationStage(FlowTestCase):
primary_action="Log in",
sources=[
{
- "icon_url": "/static/authentik/sources/default.svg",
+ "icon_url": None,
+ "icon_themed_urls": None,
"name": self.source.name,
"challenge": {
"component": "xak-flow-redirect",
@@ -523,7 +529,8 @@ class TestIdentificationStage(FlowTestCase):
"component": "xak-flow-redirect",
"to": f"/source/oauth/login/{self.source.slug}/",
},
- "icon_url": "/static/authentik/sources/default.svg",
+ "icon_url": None,
+ "icon_themed_urls": None,
"name": self.source.name,
"promoted": False,
}
@@ -551,7 +558,8 @@ class TestIdentificationStage(FlowTestCase):
"component": "xak-flow-redirect",
"to": f"/source/oauth/login/{self.source.slug}/",
},
- "icon_url": "/static/authentik/sources/default.svg",
+ "icon_url": None,
+ "icon_themed_urls": None,
"name": self.source.name,
"promoted": False,
}
diff --git a/schema.yml b/schema.yml
index f5f27ba053..c6a451ba37 100644
--- a/schema.yml
+++ b/schema.yml
@@ -40860,6 +40860,9 @@ components:
type: string
icon_url:
type: string
+ nullable: true
+ description: Get the URL to the source icon. Only returns user-configured
+ icons.
readOnly: true
icon_themed_urls:
allOf:
@@ -41550,6 +41553,9 @@ components:
type: string
icon_url:
type: string
+ nullable: true
+ description: Get the URL to the source icon. Only returns user-configured
+ icons.
readOnly: true
icon_themed_urls:
allOf:
@@ -42353,6 +42359,10 @@ components:
icon_url:
type: string
nullable: true
+ icon_themed_urls:
+ allOf:
+ - $ref: '#/components/schemas/ThemedUrls'
+ nullable: true
promoted:
type: boolean
default: false
@@ -43755,6 +43765,8 @@ components:
icon_url:
type: string
nullable: true
+ description: Get the URL to the source icon. Only returns user-configured
+ icons.
readOnly: true
icon_themed_urls:
allOf:
@@ -51112,6 +51124,9 @@ components:
type: string
icon_url:
type: string
+ nullable: true
+ description: Get the URL to the source icon. Only returns user-configured
+ icons.
readOnly: true
icon_themed_urls:
allOf:
@@ -53781,6 +53796,9 @@ components:
type: string
icon_url:
type: string
+ nullable: true
+ description: Get the URL to the source icon. Only returns user-configured
+ icons.
readOnly: true
icon_themed_urls:
allOf:
@@ -55436,7 +55454,8 @@ components:
icon_url:
type: string
nullable: true
- description: Get the URL to the source icon
+ description: Get the URL to the source icon. Only returns user-configured
+ icons.
readOnly: true
icon_themed_urls:
allOf:
@@ -56118,6 +56137,8 @@ components:
icon_url:
type: string
nullable: true
+ description: Get the URL to the source icon. Only returns user-configured
+ icons.
readOnly: true
icon_themed_urls:
allOf:
@@ -56580,6 +56601,11 @@ components:
type: string
icon_url:
type: string
+ nullable: true
+ icon_themed_urls:
+ allOf:
+ - $ref: '#/components/schemas/ThemedUrls'
+ nullable: true
requires_enterprise:
type: boolean
default: false
@@ -57609,6 +57635,11 @@ components:
type: string
icon_url:
type: string
+ nullable: true
+ icon_themed_urls:
+ allOf:
+ - $ref: '#/components/schemas/ThemedUrls'
+ nullable: true
required:
- component
- object_uid
diff --git a/web/authentik/sources/apple/dark.svg b/web/authentik/sources/apple/dark.svg
new file mode 100644
index 0000000000..6d3e92f769
--- /dev/null
+++ b/web/authentik/sources/apple/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/apple/light.svg b/web/authentik/sources/apple/light.svg
new file mode 100644
index 0000000000..f4bcba9d82
--- /dev/null
+++ b/web/authentik/sources/apple/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/azuread/dark.svg b/web/authentik/sources/azuread/dark.svg
new file mode 100644
index 0000000000..767552cd55
--- /dev/null
+++ b/web/authentik/sources/azuread/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/azuread/light.svg b/web/authentik/sources/azuread/light.svg
new file mode 100644
index 0000000000..d320198e65
--- /dev/null
+++ b/web/authentik/sources/azuread/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/discord/dark.svg b/web/authentik/sources/discord/dark.svg
new file mode 100644
index 0000000000..3f2c5fb581
--- /dev/null
+++ b/web/authentik/sources/discord/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/discord/light.svg b/web/authentik/sources/discord/light.svg
new file mode 100644
index 0000000000..11df26b303
--- /dev/null
+++ b/web/authentik/sources/discord/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/dropbox/dark.svg b/web/authentik/sources/dropbox/dark.svg
new file mode 100644
index 0000000000..73063cb9e8
--- /dev/null
+++ b/web/authentik/sources/dropbox/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/dropbox/light.svg b/web/authentik/sources/dropbox/light.svg
new file mode 100644
index 0000000000..db5e739e61
--- /dev/null
+++ b/web/authentik/sources/dropbox/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/entraid/dark.svg b/web/authentik/sources/entraid/dark.svg
new file mode 100644
index 0000000000..767552cd55
--- /dev/null
+++ b/web/authentik/sources/entraid/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/entraid/light.svg b/web/authentik/sources/entraid/light.svg
new file mode 100644
index 0000000000..d320198e65
--- /dev/null
+++ b/web/authentik/sources/entraid/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/facebook/dark.svg b/web/authentik/sources/facebook/dark.svg
new file mode 100644
index 0000000000..cee72684f1
--- /dev/null
+++ b/web/authentik/sources/facebook/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/facebook/light.svg b/web/authentik/sources/facebook/light.svg
new file mode 100644
index 0000000000..4ebb4b770e
--- /dev/null
+++ b/web/authentik/sources/facebook/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/github/dark.svg b/web/authentik/sources/github/dark.svg
new file mode 100644
index 0000000000..50f2fd261e
--- /dev/null
+++ b/web/authentik/sources/github/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/github/light.svg b/web/authentik/sources/github/light.svg
new file mode 100644
index 0000000000..8b406acd65
--- /dev/null
+++ b/web/authentik/sources/github/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/gitlab/dark.svg b/web/authentik/sources/gitlab/dark.svg
new file mode 100644
index 0000000000..385e4a3560
--- /dev/null
+++ b/web/authentik/sources/gitlab/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/gitlab/light.svg b/web/authentik/sources/gitlab/light.svg
new file mode 100644
index 0000000000..15088b77a5
--- /dev/null
+++ b/web/authentik/sources/gitlab/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/google/dark.svg b/web/authentik/sources/google/dark.svg
new file mode 100644
index 0000000000..17d3daca09
--- /dev/null
+++ b/web/authentik/sources/google/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/google/light.svg b/web/authentik/sources/google/light.svg
new file mode 100644
index 0000000000..057f1e6721
--- /dev/null
+++ b/web/authentik/sources/google/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/reddit/dark.svg b/web/authentik/sources/reddit/dark.svg
new file mode 100644
index 0000000000..659424f77e
--- /dev/null
+++ b/web/authentik/sources/reddit/dark.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/authentik/sources/reddit/light.svg b/web/authentik/sources/reddit/light.svg
new file mode 100644
index 0000000000..581a83006d
--- /dev/null
+++ b/web/authentik/sources/reddit/light.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/authentik/sources/saml/dark.svg b/web/authentik/sources/saml/dark.svg
new file mode 100644
index 0000000000..730a5f66d2
--- /dev/null
+++ b/web/authentik/sources/saml/dark.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/authentik/sources/saml/light.svg b/web/authentik/sources/saml/light.svg
new file mode 100644
index 0000000000..00b2ebaa29
--- /dev/null
+++ b/web/authentik/sources/saml/light.svg
@@ -0,0 +1,5 @@
+
diff --git a/web/authentik/sources/scim/dark.svg b/web/authentik/sources/scim/dark.svg
new file mode 100644
index 0000000000..128458ff3e
--- /dev/null
+++ b/web/authentik/sources/scim/dark.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/authentik/sources/scim/light.svg b/web/authentik/sources/scim/light.svg
new file mode 100644
index 0000000000..4ee0dbe366
--- /dev/null
+++ b/web/authentik/sources/scim/light.svg
@@ -0,0 +1,6 @@
+
diff --git a/web/authentik/sources/slack/dark.svg b/web/authentik/sources/slack/dark.svg
new file mode 100644
index 0000000000..07a9f5d8f9
--- /dev/null
+++ b/web/authentik/sources/slack/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/slack/light.svg b/web/authentik/sources/slack/light.svg
new file mode 100644
index 0000000000..9e75176fef
--- /dev/null
+++ b/web/authentik/sources/slack/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/telegram/dark.svg b/web/authentik/sources/telegram/dark.svg
new file mode 100644
index 0000000000..d04f454517
--- /dev/null
+++ b/web/authentik/sources/telegram/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/telegram/light.svg b/web/authentik/sources/telegram/light.svg
new file mode 100644
index 0000000000..354aebb4b6
--- /dev/null
+++ b/web/authentik/sources/telegram/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/twitch/dark.svg b/web/authentik/sources/twitch/dark.svg
new file mode 100644
index 0000000000..0b31e0d6a2
--- /dev/null
+++ b/web/authentik/sources/twitch/dark.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/authentik/sources/twitch/light.svg b/web/authentik/sources/twitch/light.svg
new file mode 100644
index 0000000000..3be4a42624
--- /dev/null
+++ b/web/authentik/sources/twitch/light.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/authentik/sources/twitter/dark.svg b/web/authentik/sources/twitter/dark.svg
new file mode 100644
index 0000000000..f632857642
--- /dev/null
+++ b/web/authentik/sources/twitter/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/twitter/light.svg b/web/authentik/sources/twitter/light.svg
new file mode 100644
index 0000000000..947a4f77fe
--- /dev/null
+++ b/web/authentik/sources/twitter/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/wechat/dark.svg b/web/authentik/sources/wechat/dark.svg
new file mode 100644
index 0000000000..fd12793c9d
--- /dev/null
+++ b/web/authentik/sources/wechat/dark.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/authentik/sources/wechat/light.svg b/web/authentik/sources/wechat/light.svg
new file mode 100644
index 0000000000..f109f383ea
--- /dev/null
+++ b/web/authentik/sources/wechat/light.svg
@@ -0,0 +1,4 @@
+
diff --git a/web/authentik/sources/wsfed/dark.svg b/web/authentik/sources/wsfed/dark.svg
new file mode 100644
index 0000000000..767552cd55
--- /dev/null
+++ b/web/authentik/sources/wsfed/dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/authentik/sources/wsfed/light.svg b/web/authentik/sources/wsfed/light.svg
new file mode 100644
index 0000000000..d320198e65
--- /dev/null
+++ b/web/authentik/sources/wsfed/light.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/src/elements/sources/utils.ts b/web/src/elements/sources/utils.ts
index a534caf78e..f9ba299b6b 100644
--- a/web/src/elements/sources/utils.ts
+++ b/web/src/elements/sources/utils.ts
@@ -1,21 +1,41 @@
import { PolicyBindingCheckTarget } from "#common/policies/utils";
+import { ResolvedUITheme } from "#common/theme";
+
+import { ThemedUrls } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
-export function renderSourceIcon(name: string, iconUrl: string | undefined | null): TemplateResult {
+function resolveSourceIconUrl(
+ iconUrl: string | undefined | null,
+ iconThemedUrls: ThemedUrls | undefined | null,
+ theme: ResolvedUITheme | undefined,
+): string | undefined | null {
+ if (theme && iconThemedUrls?.[theme]) {
+ return iconThemedUrls[theme];
+ }
+ return iconUrl;
+}
+
+export function renderSourceIcon(
+ name: string,
+ iconUrl: string | undefined | null,
+ iconThemedUrls?: ThemedUrls | null,
+ theme?: ResolvedUITheme,
+): TemplateResult {
+ const resolvedIconUrl = resolveSourceIconUrl(iconUrl, iconThemedUrls, theme);
const icon = html``;
- if (iconUrl) {
- if (iconUrl.startsWith("fa://")) {
- const url = iconUrl.replaceAll("fa://", "");
+ if (resolvedIconUrl) {
+ if (resolvedIconUrl.startsWith("fa://")) {
+ const url = resolvedIconUrl.replaceAll("fa://", "");
return html``;
}
- return html``;
+ return html`
`;
}
return icon;
}
diff --git a/web/src/elements/user/sources/SourceSettings.css b/web/src/elements/user/sources/SourceSettings.css
index b9f83dd161..79afb11c42 100644
--- a/web/src/elements/user/sources/SourceSettings.css
+++ b/web/src/elements/user/sources/SourceSettings.css
@@ -24,7 +24,7 @@
padding-inline-start: 0;
border-top-color: var(--ak-dark-background-lighter);
- .pf-c-data-list__cell img {
+ .pf-c-data-list__cell i[part="source-icon"] {
filter: invert(1);
}
diff --git a/web/src/elements/user/sources/SourceSettings.ts b/web/src/elements/user/sources/SourceSettings.ts
index d6ad712daa..6ababe5aa9 100644
--- a/web/src/elements/user/sources/SourceSettings.ts
+++ b/web/src/elements/user/sources/SourceSettings.ts
@@ -142,7 +142,6 @@ export class UserSourceSettingsPage extends AKElement {
const connection = this.sourceToConnection.get(source);
const connectionPk = connection?.pk ?? -1;
const connectionUserPk = connection?.user ?? -1;
-
return html`