mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
schema.yml
84
schema.yml
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user