admin/files: add centralized theme variable support for file URLs (#19657)

* Revert "admin/files: support %(theme)s variable in media file paths (#19108)"

This reverts commit 1a963d27c8.

* admin/files: add centralized theme variable support for file URLs

Overview:

Adds support for `%(theme)s` placeholder in file paths, which allows theme-specific assets (like logos, backgrounds, icons) to be served based on the user's current theme (light/dark).

This replaces the previous implementation (reverted in this PR) which only handled theme substitution in the Go file backend and instead uses the new approach which centralizes theme logic and works across both backends.

Testing:

Try out the following for the file and s3 backend:

* Ensure themed images load
* Ensure non-themed images load

Motivation:

Internal

* brands: fix tests

* admin/files: s3 backend: fix tests

.xyz is a known MIME type for chemical/molecular structure files

* admin/files: api: fix tests

* core: fix tests

* admin/files: manager: fix tests

* admin/files: Support themed urls for passthrough backend

* admin/files: Create and use ThemedUrlsSerializer

* root: Regenerate

* core: Add read_only=True since it's a computed field from the model

* root: Regenerate

* web: Use the ThemedUrlsSerializer

* web, core: Fix frontend build

* core: Lint

* admin/files: Fix tests following CodeQL

* flows, providers: fix tests
This commit is contained in:
Dominic R
2026-01-27 08:09:42 -05:00
committed by GitHub
parent bd007344dd
commit 33594c9cb4
40 changed files with 594 additions and 210 deletions

View File

@@ -1,5 +1,3 @@
import mimetypes
from django.db.models import Q
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema
@@ -12,13 +10,14 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.admin.files.backends.base import get_content_type
from authentik.admin.files.fields import FileField as AkFileField
from authentik.admin.files.manager import get_file_manager
from authentik.admin.files.usage import FileApiUsage
from authentik.admin.files.validation import validate_upload_file_name
from authentik.api.validation import validate
from authentik.core.api.used_by import DeleteAction, UsedBySerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.api.utils import PassiveSerializer, ThemedUrlsSerializer
from authentik.events.models import Event, EventAction
from authentik.lib.utils.reflection import get_apps
from authentik.rbac.permissions import HasPermission
@@ -26,11 +25,6 @@ from authentik.rbac.permissions import HasPermission
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25MB
def get_mime_from_filename(filename: str) -> str:
mime_type, _ = mimetypes.guess_type(filename)
return mime_type or "application/octet-stream"
class FileView(APIView):
pagination_class = None
parser_classes = [MultiPartParser]
@@ -53,6 +47,7 @@ class FileView(APIView):
name = CharField()
mime_type = CharField()
url = CharField()
themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
@extend_schema(
parameters=[FileListParameters],
@@ -80,8 +75,9 @@ class FileView(APIView):
FileView.FileListSerializer(
data={
"name": file,
"url": manager.file_url(file),
"mime_type": get_mime_from_filename(file),
"url": manager.file_url(file, request),
"mime_type": get_content_type(file),
"themed_urls": manager.themed_urls(file, request),
}
)
for file in files
@@ -150,7 +146,7 @@ class FileView(APIView):
"pk": name,
"name": name,
"usage": usage.value,
"mime_type": get_mime_from_filename(name),
"mime_type": get_content_type(name),
},
).from_http(request)

View File

@@ -1,3 +1,4 @@
import mimetypes
from collections.abc import Callable, Generator, Iterator
from typing import cast
@@ -10,6 +11,32 @@ from authentik.admin.files.usage import FileUsage
CACHE_PREFIX = "goauthentik.io/admin/files"
LOGGER = get_logger()
# Theme variable placeholder for theme-specific files like logo-%(theme)s.png
THEME_VARIABLE = "%(theme)s"
def get_content_type(name: str) -> str:
"""Get MIME type for a file based on its extension."""
content_type, _ = mimetypes.guess_type(name)
return content_type or "application/octet-stream"
def get_valid_themes() -> list[str]:
"""Get valid themes that can be substituted for %(theme)s."""
from authentik.brands.api import Themes
return [t.value for t in Themes if t != Themes.AUTOMATIC]
def has_theme_variable(name: str) -> bool:
"""Check if filename contains %(theme)s variable."""
return THEME_VARIABLE in name
def substitute_theme(name: str, theme: str) -> str:
"""Replace %(theme)s with the given theme."""
return name.replace(THEME_VARIABLE, theme)
class Backend:
"""
@@ -75,6 +102,29 @@ class Backend:
"""
raise NotImplementedError
def themed_urls(
self,
name: str,
request: HttpRequest | None = None,
) -> dict[str, str] | None:
"""
Get URLs for each theme variant when filename contains %(theme)s.
Args:
name: File path potentially containing %(theme)s
request: Optional Django HttpRequest for URL building
Returns:
Dict mapping theme to URL if %(theme)s present, None otherwise
"""
if not has_theme_variable(name):
return None
return {
theme: self.file_url(substitute_theme(name, theme), request, use_cache=True)
for theme in get_valid_themes()
}
class ManageableBackend(Backend):
"""

View File

@@ -46,3 +46,25 @@ class PassthroughBackend(Backend):
) -> str:
"""Return the URL as-is for passthrough files."""
return name
def themed_urls(
self,
name: str,
request: HttpRequest | None = None,
) -> dict[str, str] | None:
"""Support themed URLs for external URLs with %(theme)s placeholder.
If the external URL contains %(theme)s, substitute it for each theme.
We can't verify that themed variants exist at the external location,
but we trust the user to provide valid URLs.
"""
from authentik.admin.files.backends.base import (
get_valid_themes,
has_theme_variable,
substitute_theme,
)
if not has_theme_variable(name):
return None
return {theme: substitute_theme(name, theme) for theme in get_valid_themes()}

View File

@@ -9,7 +9,7 @@ from botocore.exceptions import ClientError
from django.db import connection
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import ManageableBackend
from authentik.admin.files.backends.base import ManageableBackend, get_content_type
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
@@ -204,6 +204,7 @@ class S3Backend(ManageableBackend):
Key=f"{self.base_path}/{name}",
Body=content,
ACL="private",
ContentType=get_content_type(name),
)
@contextmanager
@@ -219,6 +220,7 @@ class S3Backend(ManageableBackend):
Key=f"{self.base_path}/{name}",
ExtraArgs={
"ACL": "private",
"ContentType": get_content_type(name),
},
)

View File

@@ -165,3 +165,31 @@ class TestFileBackend(FileTestFileBackendMixin, TestCase):
def test_file_exists_false(self):
"""Test file_exists returns False for nonexistent file"""
self.assertFalse(self.backend.file_exists("does_not_exist.txt"))
def test_themed_urls_without_theme_variable(self):
"""Test themed_urls returns None when filename has no %(theme)s"""
file_name = "logo.png"
result = self.backend.themed_urls(file_name)
self.assertIsNone(result)
def test_themed_urls_with_theme_variable(self):
"""Test themed_urls returns dict of URLs for each theme"""
file_name = "logo-%(theme)s.png"
result = self.backend.themed_urls(file_name)
self.assertIsInstance(result, dict)
self.assertIn("light", result)
self.assertIn("dark", result)
# Check URLs contain the substituted theme
self.assertIn("logo-light.png", result["light"])
self.assertIn("logo-dark.png", result["dark"])
def test_themed_urls_multiple_theme_variables(self):
"""Test themed_urls with multiple %(theme)s in path"""
file_name = "%(theme)s/logo-%(theme)s.svg"
result = self.backend.themed_urls(file_name)
self.assertIsInstance(result, dict)
self.assertIn("light/logo-light.svg", result["light"])
self.assertIn("dark/logo-dark.svg", result["dark"])

View File

@@ -145,3 +145,71 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
f"Bucket name '{bucket_name}' appears {bucket_occurrences} times in URL, expected 1. "
f"URL: {url}",
)
def test_themed_urls_without_theme_variable(self):
"""Test themed_urls returns None when filename has no %(theme)s"""
result = self.media_s3_backend.themed_urls("logo.png")
self.assertIsNone(result)
def test_themed_urls_with_theme_variable(self):
"""Test themed_urls returns dict of presigned URLs for each theme"""
result = self.media_s3_backend.themed_urls("logo-%(theme)s.png")
self.assertIsInstance(result, dict)
self.assertIn("light", result)
self.assertIn("dark", result)
# Check URLs are valid presigned URLs with correct file paths
self.assertIn("logo-light.png", result["light"])
self.assertIn("logo-dark.png", result["dark"])
self.assertIn("X-Amz-Signature=", result["light"])
self.assertIn("X-Amz-Signature=", result["dark"])
def test_themed_urls_multiple_theme_variables(self):
"""Test themed_urls with multiple %(theme)s in path"""
result = self.media_s3_backend.themed_urls("%(theme)s/logo-%(theme)s.svg")
self.assertIsInstance(result, dict)
self.assertIn("light/logo-light.svg", result["light"])
self.assertIn("dark/logo-dark.svg", result["dark"])
def test_save_file_sets_content_type_svg(self):
"""Test save_file sets correct ContentType for SVG files"""
self.media_s3_backend.save_file("test.svg", b"<svg></svg>")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.svg",
)
self.assertEqual(response["ContentType"], "image/svg+xml")
def test_save_file_sets_content_type_png(self):
"""Test save_file sets correct ContentType for PNG files"""
self.media_s3_backend.save_file("test.png", b"\x89PNG\r\n\x1a\n")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.png",
)
self.assertEqual(response["ContentType"], "image/png")
def test_save_file_stream_sets_content_type(self):
"""Test save_file_stream sets correct ContentType"""
with self.media_s3_backend.save_file_stream("test.css") as f:
f.write(b"body { color: red; }")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.css",
)
self.assertEqual(response["ContentType"], "text/css")
def test_save_file_unknown_extension_octet_stream(self):
"""Test save_file sets octet-stream for unknown extensions"""
self.media_s3_backend.save_file("test.unknownext123", b"data")
response = self.media_s3_backend.client.head_object(
Bucket=self.media_s3_bucket_name,
Key="media/public/test.unknownext123",
)
self.assertEqual(response["ContentType"], "application/octet-stream")

View File

@@ -88,6 +88,28 @@ class FileManager:
LOGGER.warning(f"Could not find file backend for file: {name}")
return ""
def themed_urls(
self,
name: str | None,
request: HttpRequest | Request | None = None,
) -> dict[str, str] | None:
"""
Get URLs for each theme variant when filename contains %(theme)s.
Returns dict mapping theme to URL if %(theme)s present, None otherwise.
"""
if not name:
return None
if isinstance(request, Request):
request = request._request
for backend in self.backends:
if backend.supports_file(name):
return backend.themed_urls(name, request)
return None
def _check_manageable(self) -> None:
if not self.manageable:
raise ImproperlyConfigured("No file management backend configured.")

View File

@@ -5,7 +5,6 @@ from io import BytesIO
from django.test import TestCase
from django.urls import reverse
from authentik.admin.files.api import get_mime_from_filename
from authentik.admin.files.manager import FileManager
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
from authentik.admin.files.usage import FileUsage
@@ -94,8 +93,9 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "/static/authentik/sources/ldap.png",
"url": "http://testserver/static/authentik/sources/ldap.png",
"mime_type": "image/png",
"themed_urls": None,
},
response.data,
)
@@ -129,8 +129,9 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
self.assertIn(
{
"name": "/static/authentik/sources/ldap.png",
"url": "/static/authentik/sources/ldap.png",
"url": "http://testserver/static/authentik/sources/ldap.png",
"mime_type": "image/png",
"themed_urls": None,
},
response.data,
)
@@ -200,30 +201,64 @@ class TestFileAPI(FileTestFileBackendMixin, TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("field is required", str(response.data))
def test_list_files_includes_themed_urls_none(self):
"""Test listing files includes themed_urls as None for non-themed files"""
manager = FileManager(FileUsage.MEDIA)
file_name = "test-no-theme.png"
manager.save_file(file_name, b"test content")
class TestGetMimeFromFilename(TestCase):
"""Test get_mime_from_filename function"""
response = self.client.get(
reverse("authentik_api:files", query={"search": file_name, "manageableOnly": "true"})
)
def test_image_png(self):
"""Test PNG image MIME type"""
self.assertEqual(get_mime_from_filename("test.png"), "image/png")
self.assertEqual(response.status_code, 200)
file_entry = next((f for f in response.data if f["name"] == file_name), None)
self.assertIsNotNone(file_entry)
self.assertIn("themed_urls", file_entry)
self.assertIsNone(file_entry["themed_urls"])
def test_image_jpeg(self):
"""Test JPEG image MIME type"""
self.assertEqual(get_mime_from_filename("test.jpg"), "image/jpeg")
manager.delete_file(file_name)
def test_image_svg(self):
"""Test SVG image MIME type"""
self.assertEqual(get_mime_from_filename("test.svg"), "image/svg+xml")
def test_list_files_includes_themed_urls_dict(self):
"""Test listing files includes themed_urls as dict for themed files"""
manager = FileManager(FileUsage.MEDIA)
file_name = "logo-%(theme)s.svg"
manager.save_file("logo-light.svg", b"<svg>light</svg>")
manager.save_file("logo-dark.svg", b"<svg>dark</svg>")
manager.save_file(file_name, b"<svg>placeholder</svg>")
def test_text_plain(self):
"""Test text file MIME type"""
self.assertEqual(get_mime_from_filename("test.txt"), "text/plain")
response = self.client.get(
reverse("authentik_api:files", query={"search": "%(theme)s", "manageableOnly": "true"})
)
def test_unknown_extension(self):
"""Test unknown extension returns octet-stream"""
self.assertEqual(get_mime_from_filename("test.unknown"), "application/octet-stream")
self.assertEqual(response.status_code, 200)
file_entry = next((f for f in response.data if f["name"] == file_name), None)
self.assertIsNotNone(file_entry)
self.assertIn("themed_urls", file_entry)
self.assertIsInstance(file_entry["themed_urls"], dict)
self.assertIn("light", file_entry["themed_urls"])
self.assertIn("dark", file_entry["themed_urls"])
def test_no_extension(self):
"""Test no extension returns octet-stream"""
self.assertEqual(get_mime_from_filename("test"), "application/octet-stream")
manager.delete_file(file_name)
manager.delete_file("logo-light.svg")
manager.delete_file("logo-dark.svg")
def test_upload_file_with_theme_variable(self):
"""Test uploading file with %(theme)s in name"""
manager = FileManager(FileUsage.MEDIA)
file_name = "brand-logo-%(theme)s.svg"
file_content = b"<svg></svg>"
response = self.client.post(
reverse("authentik_api:files"),
{
"file": BytesIO(file_content),
"name": file_name,
"usage": FileUsage.MEDIA.value,
},
format="multipart",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(manager.file_exists(file_name))
manager.delete_file(file_name)

View File

@@ -1,6 +1,7 @@
"""Test file service layer"""
from unittest import skipUnless
from urllib.parse import urlparse
from django.http import HttpRequest
from django.test import TestCase
@@ -104,3 +105,71 @@ class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
# S3 URLs should be returned as-is (already absolute)
self.assertTrue(result.startswith("http://s3.test:8080/test"))
class TestThemedUrls(FileTestFileBackendMixin, TestCase):
"""Test FileManager.themed_urls method"""
def test_themed_urls_none_path(self):
"""Test themed_urls returns None for None path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls(None)
self.assertIsNone(result)
def test_themed_urls_empty_path(self):
"""Test themed_urls returns None for empty path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("")
self.assertIsNone(result)
def test_themed_urls_no_theme_variable(self):
"""Test themed_urls returns None when no %(theme)s in path"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("logo.png")
self.assertIsNone(result)
def test_themed_urls_with_theme_variable(self):
"""Test themed_urls returns dict of URLs for each theme"""
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("logo-%(theme)s.png")
self.assertIsInstance(result, dict)
self.assertIn("light", result)
self.assertIn("dark", result)
self.assertIn("logo-light.png", result["light"])
self.assertIn("logo-dark.png", result["dark"])
def test_themed_urls_with_request(self):
"""Test themed_urls builds absolute URLs with request"""
mock_request = HttpRequest()
mock_request.META = {
"HTTP_HOST": "example.com",
"SERVER_NAME": "example.com",
}
manager = FileManager(FileUsage.MEDIA)
result = manager.themed_urls("logo-%(theme)s.svg", mock_request)
self.assertIsInstance(result, dict)
light_url = urlparse(result["light"])
dark_url = urlparse(result["dark"])
self.assertEqual(light_url.scheme, "http")
self.assertEqual(light_url.netloc, "example.com")
self.assertEqual(dark_url.scheme, "http")
self.assertEqual(dark_url.netloc, "example.com")
def test_themed_urls_passthrough_with_theme_variable(self):
"""Test themed_urls returns dict for passthrough URLs with %(theme)s"""
manager = FileManager(FileUsage.MEDIA)
# External URLs with %(theme)s should return themed URLs
result = manager.themed_urls("https://example.com/logo-%(theme)s.png")
self.assertIsInstance(result, dict)
self.assertEqual(result["light"], "https://example.com/logo-light.png")
self.assertEqual(result["dark"], "https://example.com/logo-dark.png")
def test_themed_urls_passthrough_without_theme_variable(self):
"""Test themed_urls returns None for passthrough URLs without %(theme)s"""
manager = FileManager(FileUsage.MEDIA)
# External URLs without %(theme)s should return None
result = manager.themed_urls("https://example.com/logo.png")
self.assertIsNone(result)

View File

@@ -4,6 +4,7 @@ from pathlib import PurePosixPath
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from authentik.admin.files.backends.base import THEME_VARIABLE
from authentik.admin.files.backends.passthrough import PassthroughBackend
from authentik.admin.files.backends.static import StaticBackend
from authentik.admin.files.usage import FileUsage
@@ -12,10 +13,6 @@ from authentik.admin.files.usage import FileUsage
MAX_FILE_NAME_LENGTH = 1024
MAX_PATH_COMPONENT_LENGTH = 255
# Theme variable placeholder that can be used in file paths
# This allows for theme-specific files like logo-%(theme)s.png
THEME_VARIABLE = "%(theme)s"
def validate_file_name(name: str) -> None:
if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend(
@@ -44,16 +41,16 @@ def validate_upload_file_name(
raise ValidationError(_("File name cannot be empty"))
# Allow %(theme)s placeholder for theme-specific files
# We temporarily replace it for validation, then check the result
# Replace with placeholder for validation, then check the result
name_for_validation = name.replace(THEME_VARIABLE, "theme")
# Same regex is used in the frontend as well (without %(theme)s handling there)
# Same regex is used in the frontend as well (with %(theme)s handling)
if not re.match(r"^[a-zA-Z0-9._/-]+$", name_for_validation):
raise ValidationError(
_(
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
"dots (.), hyphens (-), underscores (_), forward slashes (/), "
"and the special placeholder %(theme)s for theme-specific files"
"and the placeholder %(theme)s for theme-specific files"
)
)

View File

@@ -6,7 +6,12 @@ from django.db import models
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
from rest_framework.fields import (
CharField,
ChoiceField,
ListField,
SerializerMethodField,
)
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
@@ -16,7 +21,7 @@ from rest_framework.viewsets import ModelViewSet
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer, ThemedUrlsSerializer
from authentik.rbac.filters import SecretKeyFilter
from authentik.tenants.api.settings import FlagJSONField
from authentik.tenants.flags import Flag
@@ -90,7 +95,9 @@ class CurrentBrandSerializer(PassiveSerializer):
matched_domain = CharField(source="domain")
branding_title = CharField()
branding_logo = CharField(source="branding_logo_url")
branding_logo_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
branding_favicon = CharField(source="branding_favicon_url")
branding_favicon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
branding_custom_css = CharField()
ui_footer_links = ListField(
child=FooterLinkSerializer(),

View File

@@ -89,14 +89,26 @@ class Brand(SerializerModel):
"""Get branding_logo URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_logo)
def branding_logo_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for branding_logo if it contains %(theme)s"""
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_logo)
def branding_favicon_url(self) -> str:
"""Get branding_favicon URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_favicon)
def branding_favicon_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for branding_favicon if it contains %(theme)s"""
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_favicon)
def branding_default_flow_background_url(self) -> str:
"""Get branding_default_flow_background URL"""
return get_file_manager(FileUsage.MEDIA).file_url(self.branding_default_flow_background)
def branding_default_flow_background_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for branding_default_flow_background if it contains %(theme)s"""
return get_file_manager(FileUsage.MEDIA).themed_urls(self.branding_default_flow_background)
@property
def serializer(self) -> type[Serializer]:
from authentik.brands.api import BrandSerializer

View File

@@ -6,7 +6,6 @@ from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.brands.api import Themes
from authentik.brands.models import Brand
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
@@ -33,12 +32,14 @@ class TestBrands(APITestCase):
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"ui_theme": "automatic",
"default_locale": "",
"flags": self.default_flags,
},
@@ -53,12 +54,14 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"ui_theme": "automatic",
"default_locale": "",
"flags": self.default_flags,
},
@@ -70,12 +73,14 @@ class TestBrands(APITestCase):
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "fallback",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"ui_theme": "automatic",
"default_locale": "",
"flags": self.default_flags,
},
@@ -92,12 +97,14 @@ class TestBrands(APITestCase):
response,
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "authentik-default",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"ui_theme": "automatic",
"default_locale": "",
"flags": self.default_flags,
},
@@ -115,12 +122,14 @@ class TestBrands(APITestCase):
response,
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "authentik-default",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"ui_theme": "automatic",
"default_locale": "",
"flags": self.default_flags,
},
@@ -131,12 +140,14 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"ui_theme": "automatic",
"default_locale": "",
"flags": self.default_flags,
},
@@ -152,12 +163,14 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom-strong",
"branding_custom_css": "",
"matched_domain": "foo.bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"ui_theme": "automatic",
"default_locale": "",
"flags": self.default_flags,
},
@@ -173,12 +186,14 @@ class TestBrands(APITestCase):
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_logo_themed_urls": None,
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "custom-weak",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"ui_theme": "automatic",
"default_locale": "",
"flags": self.default_flags,
},
@@ -254,12 +269,14 @@ class TestBrands(APITestCase):
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "https://goauthentik.io/img/icon.png",
"branding_logo_themed_urls": None,
"branding_favicon": "https://goauthentik.io/img/icon.png",
"branding_favicon_themed_urls": None,
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"ui_theme": "automatic",
"default_locale": "",
"flags": self.default_flags,
},

View File

@@ -24,7 +24,7 @@ from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.core.api.utils import ModelSerializer, ThemedUrlsSerializer
from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.policies.api.exec import PolicyTestResultSerializer
@@ -53,6 +53,9 @@ class ApplicationSerializer(ModelSerializer):
)
meta_icon_url = ReadOnlyField(source="get_meta_icon")
meta_icon_themed_urls = ThemedUrlsSerializer(
source="get_meta_icon_themed_urls", read_only=True, allow_null=True
)
def get_launch_url(self, app: Application) -> str | None:
"""Allow formatting of launch URL"""
@@ -102,6 +105,7 @@ class ApplicationSerializer(ModelSerializer):
"meta_launch_url",
"meta_icon",
"meta_icon_url",
"meta_icon_themed_urls",
"meta_description",
"meta_publisher",
"policy_engine_mode",

View File

@@ -14,7 +14,7 @@ from structlog.stdlib import get_logger
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer, ThemedUrlsSerializer
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
from authentik.core.types import UserSettingSerializer
from authentik.policies.engine import PolicyEngine
@@ -28,6 +28,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
managed = ReadOnlyField()
component = SerializerMethodField()
icon_url = ReadOnlyField()
icon_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
def get_component(self, obj: Source) -> str:
"""Get object component so that we know how to edit the object"""
@@ -57,6 +58,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"user_path_template",
"icon",
"icon_url",
"icon_themed_urls",
]

View File

@@ -127,3 +127,10 @@ class LinkSerializer(PassiveSerializer):
"""Returns a single link"""
link = CharField()
class ThemedUrlsSerializer(PassiveSerializer):
"""Themed URLs - maps theme names to URLs for light and dark themes"""
light = CharField(required=False, allow_null=True)
dark = CharField(required=False, allow_null=True)

View File

@@ -713,6 +713,14 @@ class Application(SerializerModel, PolicyBindingModel):
return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon)
@property
def get_meta_icon_themed_urls(self) -> dict[str, str] | None:
"""Get themed URLs for meta_icon if it contains %(theme)s"""
if not self.meta_icon:
return None
return get_file_manager(FileUsage.MEDIA).themed_urls(self.meta_icon)
def get_launch_url(self, user: User | None = None, user_data: dict | None = None) -> str | None:
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.
@@ -927,6 +935,14 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
return get_file_manager(FileUsage.MEDIA).file_url(self.icon)
@property
def icon_themed_urls(self) -> 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)
def get_user_path(self) -> str:
"""Get user path, fallback to default for formatting errors"""
try:

View File

@@ -127,6 +127,7 @@ class TestApplicationsAPI(APITestCase):
"open_in_new_tab": True,
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
@@ -184,6 +185,7 @@ class TestApplicationsAPI(APITestCase):
"open_in_new_tab": True,
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_description": "",
"meta_publisher": "",
"policy_engine_mode": "any",
@@ -193,6 +195,7 @@ class TestApplicationsAPI(APITestCase):
"meta_description": "",
"meta_icon": "",
"meta_icon_url": None,
"meta_icon_themed_urls": None,
"meta_launch_url": "",
"open_in_new_tab": False,
"meta_publisher": "",

View File

@@ -22,6 +22,7 @@ from authentik.core.api.utils import (
LinkSerializer,
ModelSerializer,
PassiveSerializer,
ThemedUrlsSerializer,
)
from authentik.events.logs import LogEventSerializer
from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer
@@ -47,6 +48,7 @@ class FlowSerializer(ModelSerializer):
"""Flow Serializer"""
background_url = ReadOnlyField()
background_themed_urls = ThemedUrlsSerializer(read_only=True, allow_null=True)
cache_count = SerializerMethodField()
export_url = SerializerMethodField()
@@ -70,6 +72,7 @@ class FlowSerializer(ModelSerializer):
"designation",
"background",
"background_url",
"background_themed_urls",
"stages",
"policies",
"cache_count",

View File

@@ -11,7 +11,7 @@ from django.http import JsonResponse
from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField
from rest_framework.request import Request
from authentik.core.api.utils import PassiveSerializer
from authentik.core.api.utils import PassiveSerializer, ThemedUrlsSerializer
from authentik.lib.utils.errors import exception_to_string
if TYPE_CHECKING:
@@ -44,6 +44,7 @@ class ContextualFlowInfo(PassiveSerializer):
title = CharField(required=False, allow_blank=True)
background = CharField(required=False)
background_themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
cancel_url = CharField()
layout = ChoiceField(choices=[(x.value, x.name) for x in FlowLayout])

View File

@@ -196,6 +196,15 @@ class Flow(SerializerModel, PolicyBindingModel):
return get_file_manager(FileUsage.MEDIA).file_url(self.background, request)
def background_themed_urls(self, request: HttpRequest | None = None) -> dict[str, str] | None:
"""Get themed URLs for background if it contains %(theme)s"""
if not self.background:
if request:
return request.brand.branding_default_flow_background_themed_urls()
return None
return get_file_manager(FileUsage.MEDIA).themed_urls(self.background, request)
stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True)
@property

View File

@@ -197,6 +197,9 @@ class ChallengeStageView(StageView):
data={
"title": self.format_title(),
"background": self.executor.flow.background_url(self.request),
"background_themed_urls": self.executor.flow.background_themed_urls(
self.request
),
"cancel_url": self.cancel_url,
"layout": self.executor.flow.layout,
}

View File

@@ -734,6 +734,7 @@ class TestFlowExecutor(FlowTestCase):
flow,
flow_info={
"background": "/static/dist/assets/images/flow_background.jpg",
"background_themed_urls": None,
"cancel_url": "/flows/-/cancel/?next=%2Ffoo",
"layout": "stacked",
"title": flow.title,

View File

@@ -51,6 +51,7 @@ class TestFlowInspector(APITestCase):
"enable_remember_me": False,
"flow_info": {
"background": "/static/dist/assets/images/flow_background.jpg",
"background_themed_urls": None,
"cancel_url": reverse("authentik_flows:cancel"),
"title": flow.title,
"layout": "stacked",

View File

@@ -84,6 +84,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
"component": "ak-provider-oauth2-device-code",
"flow_info": {
"background": "/static/dist/assets/images/flow_background.jpg",
"background_themed_urls": None,
"cancel_url": "/flows/-/cancel/",
"layout": "stacked",
"title": self.device_flow.title,

View File

@@ -5,7 +5,6 @@ import (
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-http-utils/etag"
@@ -18,44 +17,11 @@ import (
staticWeb "goauthentik.io/web"
)
// Theme variable placeholder that can be used in file paths
// This allows for theme-specific files like logo-%(theme)s.png
const themeVariable = "%(theme)s"
// Valid themes that can be substituted for %(theme)s
var validThemes = []string{"light", "dark"}
type StorageClaims struct {
jwt.RegisteredClaims
Path string `json:"path,omitempty"`
}
// pathMatchesWithTheme checks if the requested path matches the JWT path,
// accounting for theme variable substitution.
// If the JWT path contains %(theme)s, it will match the requested path
// if substituting %(theme)s with any valid theme produces the requested path.
func pathMatchesWithTheme(jwtPath, requestedPath string) bool {
// Direct match (no theme variable)
if jwtPath == requestedPath {
return true
}
// Check if JWT path contains theme variable
if !strings.Contains(jwtPath, themeVariable) {
return false
}
// Try substituting each valid theme and check for a match
for _, theme := range validThemes {
substituted := strings.ReplaceAll(jwtPath, themeVariable, theme)
if substituted == requestedPath {
return true
}
}
return false
}
func storageTokenIsValid(usage string, r *http.Request) bool {
tokenString := r.URL.Query().Get("token")
if tokenString == "" {
@@ -85,8 +51,11 @@ func storageTokenIsValid(usage string, r *http.Request) bool {
return false
}
requestedPath := fmt.Sprintf("%s/%s", usage, r.URL.Path)
return pathMatchesWithTheme(claims.Path, requestedPath)
if claims.Path != fmt.Sprintf("%s/%s", usage, r.URL.Path) {
return false
}
return true
}
func (ws *WebServer) configureStatic() {

View File

@@ -1,95 +0,0 @@
package web
import "testing"
func TestPathMatchesWithTheme(t *testing.T) {
tests := []struct {
name string
jwtPath string
requestedPath string
want bool
}{
{
name: "exact match without theme variable",
jwtPath: "media/public/logo.png",
requestedPath: "media/public/logo.png",
want: true,
},
{
name: "no match without theme variable",
jwtPath: "media/public/logo.png",
requestedPath: "media/public/other.png",
want: false,
},
{
name: "theme variable matches light theme",
jwtPath: "media/public/logo-%(theme)s.png",
requestedPath: "media/public/logo-light.png",
want: true,
},
{
name: "theme variable matches dark theme",
jwtPath: "media/public/logo-%(theme)s.png",
requestedPath: "media/public/logo-dark.png",
want: true,
},
{
name: "theme variable does not match invalid theme",
jwtPath: "media/public/logo-%(theme)s.png",
requestedPath: "media/public/logo-blue.png",
want: false,
},
{
name: "theme variable in directory path",
jwtPath: "media/%(theme)s/logo.png",
requestedPath: "media/light/logo.png",
want: true,
},
{
name: "multiple theme variables",
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
requestedPath: "media/light/logo-light.png",
want: true,
},
{
name: "multiple theme variables with dark",
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
requestedPath: "media/dark/logo-dark.png",
want: true,
},
{
name: "multiple theme variables mixed themes should not match",
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
requestedPath: "media/light/logo-dark.png",
want: false,
},
{
name: "theme variable with nested path",
jwtPath: "media/public/brand/logo-%(theme)s.svg",
requestedPath: "media/public/brand/logo-dark.svg",
want: true,
},
{
name: "empty paths",
jwtPath: "",
requestedPath: "",
want: true,
},
{
name: "theme variable only",
jwtPath: "%(theme)s",
requestedPath: "light",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pathMatchesWithTheme(tt.jwtPath, tt.requestedPath)
if got != tt.want {
t.Errorf("pathMatchesWithTheme(%q, %q) = %v, want %v",
tt.jwtPath, tt.requestedPath, got, tt.want)
}
})
}
}

View File

@@ -33412,6 +33412,11 @@ components:
nullable: true
description: Get the URL to the App Icon image
readOnly: true
meta_icon_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
meta_description:
type: string
meta_publisher:
@@ -33423,6 +33428,7 @@ components:
required:
- backchannel_providers_obj
- launch_url
- meta_icon_themed_urls
- meta_icon_url
- name
- pk
@@ -35690,6 +35696,10 @@ components:
type: string
background:
type: string
background_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
nullable: true
cancel_url:
type: string
layout:
@@ -35967,8 +35977,18 @@ components:
type: string
branding_logo:
type: string
branding_logo_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
branding_favicon:
type: string
branding_favicon_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
branding_custom_css:
type: string
ui_footer_links:
@@ -36013,7 +36033,9 @@ components:
required:
- branding_custom_css
- branding_favicon
- branding_favicon_themed_urls
- branding_logo
- branding_logo_themed_urls
- branding_title
- default_locale
- flags
@@ -37983,6 +38005,10 @@ components:
type: string
url:
type: string
themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
nullable: true
required:
- mime_type
- name
@@ -38119,6 +38145,11 @@ components:
type: string
description: Get the URL to the background image
readOnly: true
background_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
stages:
type: array
items:
@@ -38158,6 +38189,7 @@ components:
description: Required level of authentication and authorization to access
a flow.
required:
- background_themed_urls
- background_url
- cache_count
- designation
@@ -40208,6 +40240,11 @@ components:
icon_url:
type: string
readOnly: true
icon_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
group_matching_mode:
allOf:
- $ref: '#/components/schemas/GroupMatchingModeEnum'
@@ -40261,6 +40298,7 @@ components:
required:
- component
- connectivity
- icon_themed_urls
- icon_url
- managed
- meta_model_name
@@ -40892,6 +40930,11 @@ components:
icon_url:
type: string
readOnly: true
icon_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
server_uri:
type: string
format: uri
@@ -40982,6 +41025,7 @@ components:
- base_dn
- component
- connectivity
- icon_themed_urls
- icon_url
- managed
- meta_model_name
@@ -42749,6 +42793,11 @@ components:
type: string
nullable: true
readOnly: true
icon_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
group_matching_mode:
allOf:
- $ref: '#/components/schemas/GroupMatchingModeEnum'
@@ -42807,6 +42856,7 @@ components:
- callback_url
- component
- consumer_key
- icon_themed_urls
- icon_url
- managed
- meta_model_name
@@ -49851,6 +49901,11 @@ components:
icon_url:
type: string
readOnly: true
icon_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
group_matching_mode:
allOf:
- $ref: '#/components/schemas/GroupMatchingModeEnum'
@@ -49873,6 +49928,7 @@ components:
description: Plex token used to check friends
required:
- component
- icon_themed_urls
- icon_url
- managed
- meta_model_name
@@ -52421,6 +52477,11 @@ components:
icon_url:
type: string
readOnly: true
icon_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
group_matching_mode:
allOf:
- $ref: '#/components/schemas/GroupMatchingModeEnum'
@@ -52491,6 +52552,7 @@ components:
type: boolean
required:
- component
- icon_themed_urls
- icon_url
- managed
- meta_model_name
@@ -54021,8 +54083,14 @@ components:
nullable: true
description: Get the URL to the source icon
readOnly: true
icon_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
required:
- component
- icon_themed_urls
- icon_url
- managed
- meta_model_name
@@ -54696,6 +54764,11 @@ components:
type: string
nullable: true
readOnly: true
icon_themed_urls:
allOf:
- $ref: '#/components/schemas/ThemedUrls'
readOnly: true
nullable: true
bot_username:
type: string
description: Telegram bot username
@@ -54709,6 +54782,7 @@ components:
required:
- bot_username
- component
- icon_themed_urls
- icon_url
- managed
- meta_model_name
@@ -54926,6 +55000,16 @@ components:
required:
- name
- schema_name
ThemedUrls:
type: object
description: Themed URLs - maps theme names to URLs for light and dark themes
properties:
light:
type: string
nullable: true
dark:
type: string
nullable: true
Token:
type: object
description: Token Serializer

View File

@@ -100,6 +100,7 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton))
alt: msg("authentik Logo"),
className: "pf-c-about-modal-box__brand-image",
theme: this.activeTheme,
themedUrls: this.brandingFaviconThemedUrls,
})}
</div>
<div class="pf-c-about-modal-box__close">

View File

@@ -119,6 +119,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
aria-label=${msg(str`Application icon for "${item.name}"`)}
name=${item.name}
icon=${ifPresent(item.metaIconUrl)}
.iconThemedUrls=${item.metaIconThemedUrls}
></ak-app-icon>`,
html`<a href="#/core/applications/${item.slug}">
<div>${item.name}</div>

View File

@@ -14,8 +14,7 @@ import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
// Theme variable placeholder that can be used in file paths
// This allows for theme-specific files like logo-%(theme)s.png
// Theme variable placeholder for theme-specific files like logo-%(theme)s.png
const THEME_VARIABLE = "%(theme)s";
// Same regex is used in the backend as well (after replacing %(theme)s)
@@ -28,12 +27,12 @@ const VALID_FILE_NAME_PATTERN_STRING = "^[a-zA-Z0-9._\\/\\-%()+]+$";
function assertValidFileName(fileName: string): void {
// Allow %(theme)s placeholder for theme-specific files
// We temporarily replace it for validation, then check the result
// Replace with placeholder for validation, then check the result
const nameForValidation = fileName.replaceAll(THEME_VARIABLE, "theme");
if (!VALID_FILE_NAME_PATTERN.test(nameForValidation)) {
throw new Error(
msg(
"Filename can only contain letters, numbers, dots, hyphens, underscores, slashes, and the special placeholder %(theme)s",
"Filename can only contain letters, numbers, dots, hyphens, underscores, slashes, and the placeholder %(theme)s",
),
);
}

View File

@@ -6,7 +6,9 @@ import { deepmerge } from "deepmerge-ts";
export const DefaultBrand = {
brandingLogo: "/static/dist/assets/icons/icon_left_brand.svg",
brandingLogoThemedUrls: null,
brandingFavicon: "/static/dist/assets/icons/icon.png",
brandingFaviconThemedUrls: null,
brandingTitle: "authentik",
brandingCustomCss: "",
uiFooterLinks: [],

View File

@@ -169,7 +169,7 @@ export class AKPageNavbar
protected renderBrand() {
return guard(
[this.brandingLogo, this.activeTheme],
[this.brandingLogo, this.brandingLogoThemedUrls, this.activeTheme],
() =>
html`<aside role="presentation" class="brand">
<a aria-label="${msg("Home")}" href="#/">
@@ -178,6 +178,7 @@ export class AKPageNavbar
src: this.brandingLogo,
alt: msg("authentik Logo"),
theme: this.activeTheme,
themedUrls: this.brandingLogoThemedUrls,
})}
</div>
</a>

View File

@@ -4,6 +4,8 @@ import Styles from "#elements/AppIcon.css";
import { AKElement } from "#elements/Base";
import { FontAwesomeProtocol } from "#elements/utils/images";
import type { ThemedUrls } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -13,6 +15,7 @@ import PFFAIcons from "@patternfly/patternfly/base/patternfly-fa-icons.css";
export interface IAppIcon {
name?: string | null;
icon?: string | null;
iconThemedUrls?: ThemedUrls | null;
size?: PFSize | null;
}
@@ -28,6 +31,9 @@ export class AppIcon extends AKElement implements IAppIcon {
@property({ type: String })
public icon: string | null = null;
@property({ type: Object })
public iconThemedUrls: ThemedUrls | null = null;
@property({ reflect: true })
public size: PFSize = PFSize.Medium;
@@ -57,14 +63,17 @@ export class AppIcon extends AKElement implements IAppIcon {
const insignia = this.name?.charAt(0).toUpperCase() ?? "<22>";
// Check for image URLs (http://, https://, or file paths)
if (this.icon) {
// Use themed URL if available, otherwise fall back to icon
const resolvedIcon =
(this.iconThemedUrls as Record<string, string> | null)?.[this.activeTheme] ?? this.icon;
if (resolvedIcon) {
return this.#wrap(
html`<img
part="icon image"
role="img"
aria-label=${label}
class="icon"
src=${this.icon}
src=${resolvedIcon}
alt=${insignia}
/>`,
);

View File

@@ -2,7 +2,7 @@ import { DefaultBrand } from "#common/ui/config";
import { createMixin } from "#elements/types";
import type { CurrentBrand, FooterLink } from "@goauthentik/api";
import type { CurrentBrand, FooterLink, ThemedUrls } from "@goauthentik/api";
import { consume, Context, createContext } from "@lit/context";
@@ -42,6 +42,11 @@ export interface BrandingMixin {
*/
readonly brandingLogo: string;
/**
* Pre-resolved themed URLs for the logo (for S3 presigned URLs).
*/
readonly brandingLogoThemedUrls: ThemedUrls | null | undefined;
/**
* The application favicon.
*
@@ -49,6 +54,11 @@ export interface BrandingMixin {
*/
readonly brandingFavicon: string;
/**
* Pre-resolved themed URLs for the favicon (for S3 presigned URLs).
*/
readonly brandingFaviconThemedUrls: ThemedUrls | null | undefined;
/**
* Footer links provided by the brand configuration.
*/
@@ -81,10 +91,18 @@ export const WithBrandConfig = createMixin<BrandingMixin>(
return this.brand.brandingLogo ?? DefaultBrand.brandingLogo;
}
public get brandingLogoThemedUrls(): ThemedUrls | null | undefined {
return this.brand.brandingLogoThemedUrls;
}
public get brandingFavicon(): string {
return this.brand.brandingFavicon ?? DefaultBrand.brandingFavicon;
}
public get brandingFaviconThemedUrls(): ThemedUrls | null | undefined {
return this.brand.brandingFaviconThemedUrls;
}
public get brandingFooterLinks(): FooterLink[] {
return this.brand.uiFooterLinks ?? DefaultBrand.uiFooterLinks;
}

View File

@@ -3,6 +3,8 @@ import { ResolvedUITheme } from "#common/theme";
import type { LitFC } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import type { ThemedUrls } from "@goauthentik/api";
import { spread } from "@open-wc/lit-helpers";
import { ImgHTMLAttributes } from "react";
@@ -10,27 +12,31 @@ import { html, nothing } from "lit";
export const FontAwesomeProtocol = "fa://";
export function themeImage(rawPath: string, theme: ResolvedUITheme) {
return rawPath.replaceAll("%(theme)s", theme);
}
export interface ThemedImageProps extends ImgHTMLAttributes<HTMLImageElement> {
/**
* The image path, which can be:
* - A regular URL
* - A Font Awesome icon (fa://icon-name)
* - A themed image path with %(theme)s placeholder
* The image path (base URL, may contain %(theme)s for display purposes only)
*/
src: string;
theme: ResolvedUITheme;
/**
* Pre-resolved URLs for each theme variant from backend.
* When provided, these are used instead of src.
*/
themedUrls?: ThemedUrls | null;
}
export const ThemedImage: LitFC<ThemedImageProps> = ({ src, className, theme, ...props }) => {
export const ThemedImage: LitFC<ThemedImageProps> = ({
src,
className,
theme,
themedUrls,
...props
}) => {
if (!src) {
return nothing;
}
// Handle Font Awesome icons (same logic as ak-app-icon)
// Handle Font Awesome icons
if (src.startsWith(FontAwesomeProtocol)) {
const classes = [className, "font-awesome", "fas", src.slice(FontAwesomeProtocol.length)]
.filter(Boolean)
@@ -39,9 +45,10 @@ export const ThemedImage: LitFC<ThemedImageProps> = ({ src, className, theme, ..
return html`<i part="icon font-awesome" role="img" class=${classes} ${spread(props)}></i>`;
}
const themedSrc = themeImage(src, theme);
// Use themed URL if available, otherwise use src directly
const resolvedSrc = (themedUrls as Record<string, string> | null)?.[theme] ?? src;
return html`<img src=${themedSrc} class=${ifPresent(className)} ${spread(props)} />`;
return html`<img src=${resolvedSrc} class=${ifPresent(className)} ${spread(props)} />`;
};
export function isDefaultAvatar(path?: string | null): boolean {

View File

@@ -236,8 +236,16 @@ export class FlowExecutor
this.layout = this.challenge?.flowInfo?.layout || FlowExecutor.DefaultLayout;
}
if (changedProperties.has("flowInfo") && this.flowInfo) {
applyBackgroundImageProperty(this.flowInfo.background);
if (
(changedProperties.has("flowInfo") || changedProperties.has("activeTheme")) &&
this.flowInfo
) {
// Use themed background URL if available, otherwise fall back to default
const backgroundUrl =
(this.flowInfo.backgroundThemedUrls as Record<string, string> | null | undefined)?.[
this.activeTheme
] ?? this.flowInfo.background;
applyBackgroundImageProperty(backgroundUrl);
}
if (
@@ -511,6 +519,7 @@ export class FlowExecutor
alt: msg("authentik Logo"),
className: "branding-logo",
theme: this.activeTheme,
themedUrls: this.brandingLogoThemedUrls,
})}
</div>
${this.loading && this.challenge

View File

@@ -106,6 +106,7 @@ export class APIBrowser extends WithBrandConfig(Interface) {
alt: msg("authentik Logo"),
className: "logo",
theme: this.activeTheme,
themedUrls: this.brandingLogoThemedUrls,
})}
</div>
</rapi-doc>

View File

@@ -89,6 +89,7 @@ export const AKLibraryApp: LitFC<AKLibraryAppProps> = ({
size=${PFSize.Large}
name=${application.name}
icon=${ifPresent(application.metaIconUrl)}
.iconThemedUrls=${application.metaIconThemedUrls}
></ak-app-icon>
${rac
? html`<div

View File

@@ -155,6 +155,7 @@ class UserInterface extends WithBrandConfig(WithSession(AuthenticatedInterface))
alt: this.brandingTitle,
className: "pf-c-brand",
theme: this.activeTheme,
themedUrls: this.brandingLogoThemedUrls,
})}
</a>
</div>