From 9f5fb692ba88df0b9d3350fedad19da4a8303e47 Mon Sep 17 00:00:00 2001 From: Jens L Date: Wed, 16 Nov 2022 14:10:10 +0100 Subject: [PATCH] sources: add custom icon support (#4022) * add source icon Signed-off-by: Jens Langhammer * add to oauth form Signed-off-by: Jens Langhammer * add to other browser sources Signed-off-by: Jens Langhammer * add migration, return icon in UI challenges Signed-off-by: Jens Langhammer * deduplicate file upload Signed-off-by: Jens Langhammer Signed-off-by: Jens Langhammer --- authentik/core/api/applications.py | 30 ++--- authentik/core/api/sources.py | 55 ++++++++- authentik/core/api/utils.py | 15 +-- authentik/core/migrations/0024_source_icon.py | 20 +++ authentik/core/models.py | 16 +++ authentik/flows/api/flows.py | 42 ++----- authentik/lib/utils/file.py | 55 +++++++++ authentik/sources/oauth/api/source.py | 3 +- authentik/sources/oauth/models.py | 11 +- authentik/sources/plex/models.py | 10 +- authentik/sources/saml/api/source.py | 22 +++- authentik/sources/saml/models.py | 6 +- schema.yml | 115 ++++++++++++++---- .../admin/sources/oauth/OAuthSourceForm.ts | 84 ++++++++++++- web/src/admin/sources/plex/PlexSourceForm.ts | 84 ++++++++++++- web/src/admin/sources/saml/SAMLSourceForm.ts | 82 ++++++++++++- 16 files changed, 527 insertions(+), 123 deletions(-) create mode 100644 authentik/core/migrations/0024_source_icon.py create mode 100644 authentik/lib/utils/file.py diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index 632e7243e..4a52c847e 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -23,10 +23,15 @@ from authentik.admin.api.metrics import CoordinateSerializer from authentik.api.decorators import permission_required from authentik.core.api.providers import ProviderSerializer from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import FilePathSerializer, FileUploadSerializer from authentik.core.models import Application, User from authentik.events.models import EventAction from authentik.events.utils import sanitize_dict +from authentik.lib.utils.file import ( + FilePathSerializer, + FileUploadSerializer, + set_file, + set_file_url, +) from authentik.policies.api.exec import PolicyTestResultSerializer from authentik.policies.engine import PolicyEngine from authentik.policies.types import PolicyResult @@ -224,21 +229,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): def set_icon(self, request: Request, slug: str): """Set application icon""" app: Application = self.get_object() - icon = request.FILES.get("file", None) - clear = request.data.get("clear", "false").lower() == "true" - if clear: - # .delete() saves the model by default - app.meta_icon.delete() - return Response({}) - if icon: - app.meta_icon = icon - try: - app.save() - except PermissionError as exc: - LOGGER.warning("Failed to save icon", exc=exc) - return HttpResponseBadRequest() - return Response({}) - return HttpResponseBadRequest() + return set_file(request, app, "meta_icon") @permission_required("authentik_core.change_application") @extend_schema( @@ -258,12 +249,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): def set_icon_url(self, request: Request, slug: str): """Set application icon (as URL)""" app: Application = self.get_object() - url = request.data.get("url", None) - if url is None: - return HttpResponseBadRequest() - app.meta_icon.name = url - app.save() - return Response({}) + return set_file_url(request, app, "meta_icon") @permission_required("authentik_core.view_application", ["authentik_events.view_event"]) @extend_schema(responses={200: CoordinateSerializer(many=True)}) diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index 209a3eda5..899a2ab93 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -2,10 +2,11 @@ from typing import Iterable from django_filters.rest_framework import DjangoFilterBackend -from drf_spectacular.utils import extend_schema +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import mixins from rest_framework.decorators import action from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.parsers import MultiPartParser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ModelSerializer, ReadOnlyField, SerializerMethodField @@ -13,10 +14,17 @@ from rest_framework.viewsets import GenericViewSet from structlog.stdlib import get_logger from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions +from authentik.api.decorators import permission_required from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.models import Source, UserSourceConnection from authentik.core.types import UserSettingSerializer +from authentik.lib.utils.file import ( + FilePathSerializer, + FileUploadSerializer, + set_file, + set_file_url, +) from authentik.lib.utils.reflection import all_subclasses from authentik.policies.engine import PolicyEngine @@ -28,6 +36,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): managed = ReadOnlyField() component = SerializerMethodField() + icon = ReadOnlyField(source="get_icon") def get_component(self, obj: Source) -> str: """Get object component so that we know how to edit the object""" @@ -54,6 +63,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): "user_matching_mode", "managed", "user_path_template", + "icon", ] @@ -75,6 +85,49 @@ class SourceViewSet( def get_queryset(self): # pragma: no cover return Source.objects.select_subclasses() + @permission_required("authentik_core.change_source") + @extend_schema( + request={ + "multipart/form-data": FileUploadSerializer, + }, + responses={ + 200: OpenApiResponse(description="Success"), + 400: OpenApiResponse(description="Bad request"), + }, + ) + @action( + detail=True, + pagination_class=None, + filter_backends=[], + methods=["POST"], + parser_classes=(MultiPartParser,), + ) + # pylint: disable=unused-argument + def set_icon(self, request: Request, slug: str): + """Set source icon""" + source: Source = self.get_object() + return set_file(request, source, "icon") + + @permission_required("authentik_core.change_source") + @extend_schema( + request=FilePathSerializer, + responses={ + 200: OpenApiResponse(description="Success"), + 400: OpenApiResponse(description="Bad request"), + }, + ) + @action( + detail=True, + pagination_class=None, + filter_backends=[], + methods=["POST"], + ) + # pylint: disable=unused-argument + def set_icon_url(self, request: Request, slug: str): + """Set source icon (as URL)""" + source: Source = self.get_object() + return set_file_url(request, source, "icon") + @extend_schema(responses={200: TypeCreateSerializer(many=True)}) @action(detail=False, pagination_class=None, filter_backends=[]) def types(self, request: Request) -> Response: diff --git a/authentik/core/api/utils.py b/authentik/core/api/utils.py index 6c28debd6..40c9e9e1d 100644 --- a/authentik/core/api/utils.py +++ b/authentik/core/api/utils.py @@ -2,7 +2,7 @@ from typing import Any from django.db.models import Model -from rest_framework.fields import BooleanField, CharField, FileField, IntegerField +from rest_framework.fields import CharField, IntegerField from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError @@ -23,19 +23,6 @@ class PassiveSerializer(Serializer): return Model() -class FileUploadSerializer(PassiveSerializer): - """Serializer to upload file""" - - file = FileField(required=False) - clear = BooleanField(default=False) - - -class FilePathSerializer(PassiveSerializer): - """Serializer to upload file""" - - url = CharField() - - class MetaNameSerializer(PassiveSerializer): """Add verbose names to response""" diff --git a/authentik/core/migrations/0024_source_icon.py b/authentik/core/migrations/0024_source_icon.py new file mode 100644 index 000000000..79347b67e --- /dev/null +++ b/authentik/core/migrations/0024_source_icon.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.3 on 2022-11-15 20:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0023_source_authentik_c_slug_ccb2e5_idx_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="source", + name="icon", + field=models.FileField( + default=None, max_length=500, null=True, upload_to="source-icons/" + ), + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 6946adb39..687492be1 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -421,6 +421,12 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): enabled = models.BooleanField(default=True) property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) + icon = models.FileField( + upload_to="source-icons/", + default=None, + null=True, + max_length=500, + ) authentication_flow = models.ForeignKey( "authentik_flows.Flow", @@ -454,6 +460,16 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): objects = InheritanceManager() + @property + def get_icon(self) -> Optional[str]: + """Get the URL to the Icon. If the name is /static or + starts with http it is returned as-is""" + if not self.icon: + return None + if "://" in self.icon.name or self.icon.name.startswith("/static"): + return self.icon.name + return self.icon.url + def get_user_path(self) -> str: """Get user path, fallback to default for formatting errors""" try: diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index 8ab17010d..670128c26 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -1,7 +1,6 @@ """Flow API Views""" from django.core.cache import cache from django.http import HttpResponse -from django.http.response import HttpResponseBadRequest from django.urls import reverse from django.utils.translation import gettext as _ from drf_spectacular.types import OpenApiTypes @@ -19,19 +18,19 @@ from authentik.api.decorators import permission_required from authentik.blueprints.v1.exporter import FlowExporter from authentik.blueprints.v1.importer import Importer from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import ( - CacheSerializer, - FilePathSerializer, - FileUploadSerializer, - LinkSerializer, - PassiveSerializer, -) +from authentik.core.api.utils import CacheSerializer, LinkSerializer, PassiveSerializer from authentik.events.utils import sanitize_dict from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.models import Flow from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN +from authentik.lib.utils.file import ( + FilePathSerializer, + FileUploadSerializer, + set_file, + set_file_url, +) from authentik.lib.views import bad_request_message LOGGER = get_logger() @@ -249,25 +248,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): def set_background(self, request: Request, slug: str): """Set Flow background""" flow: Flow = self.get_object() - background = request.FILES.get("file", None) - clear = request.data.get("clear", "false").lower() == "true" - if clear: - if flow.background_url.startswith("/media"): - # .delete() saves the model by default - flow.background.delete() - else: - flow.background = None - flow.save() - return Response({}) - if background: - flow.background = background - try: - flow.save() - except PermissionError as exc: - LOGGER.warning("Failed to save icon", exc=exc) - return HttpResponseBadRequest() - return Response({}) - return HttpResponseBadRequest() + return set_file(request, flow, "background") @permission_required("authentik_core.change_application") @extend_schema( @@ -287,12 +268,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): def set_background_url(self, request: Request, slug: str): """Set Flow background (as URL)""" flow: Flow = self.get_object() - url = request.data.get("url", None) - if not url: - return HttpResponseBadRequest() - flow.background.name = url - flow.save() - return Response({}) + return set_file_url(request, flow, "background") @extend_schema( responses={ diff --git a/authentik/lib/utils/file.py b/authentik/lib/utils/file.py new file mode 100644 index 000000000..52e24212b --- /dev/null +++ b/authentik/lib/utils/file.py @@ -0,0 +1,55 @@ +"""file utils""" +from django.db.models import Model +from django.http import HttpResponseBadRequest +from rest_framework.fields import BooleanField, CharField, FileField +from rest_framework.request import Request +from rest_framework.response import Response +from structlog import get_logger + +from authentik.core.api.utils import PassiveSerializer + +LOGGER = get_logger() + + +class FileUploadSerializer(PassiveSerializer): + """Serializer to upload file""" + + file = FileField(required=False) + clear = BooleanField(default=False) + + +class FilePathSerializer(PassiveSerializer): + """Serializer to upload file""" + + url = CharField() + + +def set_file(request: Request, obj: Model, field: str): + """Upload file""" + field = getattr(obj, field) + icon = request.FILES.get("file", None) + clear = request.data.get("clear", "false").lower() == "true" + if clear: + # .delete() saves the model by default + field.delete() + return Response({}) + if icon: + field = icon + try: + obj.save() + except PermissionError as exc: + LOGGER.warning("Failed to save file", exc=exc) + return HttpResponseBadRequest() + return Response({}) + return HttpResponseBadRequest() + + +def set_file_url(request: Request, obj: Model, field: str): + """Set file field to URL""" + field = getattr(obj, field) + url = request.data.get("url", None) + if url is None: + return HttpResponseBadRequest() + field.name = url + obj.save() + return Response({}) diff --git a/authentik/sources/oauth/api/source.py b/authentik/sources/oauth/api/source.py index f35aa46ab..9dce61578 100644 --- a/authentik/sources/oauth/api/source.py +++ b/authentik/sources/oauth/api/source.py @@ -35,6 +35,7 @@ class OAuthSourceSerializer(SourceSerializer): provider_type = ChoiceField(choices=registry.get_name_tuple()) callback_url = SerializerMethodField() + type = SerializerMethodField() def get_callback_url(self, instance: OAuthSource) -> str: """Get OAuth Callback URL""" @@ -46,8 +47,6 @@ class OAuthSourceSerializer(SourceSerializer): return relative_url return self.context["request"].build_absolute_uri(relative_url) - type = SerializerMethodField() - @extend_schema_field(SourceTypeSerializer) def get_type(self, instance: OAuthSource) -> SourceTypeSerializer: """Get source's type configuration""" diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index 8b2bf90c1..8e559878e 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -75,15 +75,20 @@ class OAuthSource(Source): def ui_login_button(self, request: HttpRequest) -> UILoginButton: provider_type = self.type provider = provider_type() + icon = self.get_icon + if not icon: + icon = provider.icon_url() return UILoginButton( name=self.name, - icon_url=provider.icon_url(), challenge=provider.login_challenge(self, request), + icon_url=icon, ) def ui_user_settings(self) -> Optional[UserSettingSerializer]: provider_type = self.type - provider = provider_type() + icon = self.get_icon + if not icon: + icon = provider_type().icon_url() return UserSettingSerializer( data={ "title": self.name, @@ -92,7 +97,7 @@ class OAuthSource(Source): "authentik_sources_oauth:oauth-client-login", kwargs={"source_slug": self.slug}, ), - "icon_url": provider.icon_url(), + "icon_url": icon, } ) diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py index 19d232824..01b3bdfe8 100644 --- a/authentik/sources/plex/models.py +++ b/authentik/sources/plex/models.py @@ -64,6 +64,9 @@ class PlexSource(Source): return PlexSourceSerializer def ui_login_button(self, request: HttpRequest) -> UILoginButton: + icon = self.get_icon + if not icon: + icon = static("authentik/sources/plex.svg") return UILoginButton( challenge=PlexAuthenticationChallenge( { @@ -73,17 +76,20 @@ class PlexSource(Source): "slug": self.slug, } ), - icon_url=static("authentik/sources/plex.svg"), + icon_url=icon, name=self.name, ) def ui_user_settings(self) -> Optional[UserSettingSerializer]: + icon = self.get_icon + if not icon: + icon = static("authentik/sources/plex.svg") return UserSettingSerializer( data={ "title": self.name, "component": "ak-user-settings-source-plex", "configure_url": self.client_id, - "icon_url": static("authentik/sources/plex.svg"), + "icon_url": icon, } ) diff --git a/authentik/sources/saml/api/source.py b/authentik/sources/saml/api/source.py index 618dc0311..4a70b42eb 100644 --- a/authentik/sources/saml/api/source.py +++ b/authentik/sources/saml/api/source.py @@ -40,7 +40,27 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet): queryset = SAMLSource.objects.all() serializer_class = SAMLSourceSerializer lookup_field = "slug" - filterset_fields = "__all__" + filterset_fields = [ + "name", + "slug", + "enabled", + "authentication_flow", + "enrollment_flow", + "managed", + "policy_engine_mode", + "user_matching_mode", + "pre_authentication_flow", + "issuer", + "sso_url", + "slo_url", + "allow_idp_initiated", + "name_id_policy", + "binding_type", + "signing_kp", + "digest_algorithm", + "signature_algorithm", + "temporary_user_delete_after", + ] search_fields = ["name", "slug"] ordering = ["name"] diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py index 8dd30cc34..c1a3e028e 100644 --- a/authentik/sources/saml/models.py +++ b/authentik/sources/saml/models.py @@ -191,9 +191,13 @@ class SAMLSource(Source): } ), name=self.name, + icon_url=self.get_icon, ) def ui_user_settings(self) -> Optional[UserSettingSerializer]: + icon = self.get_icon + if not icon: + icon = static(f"authentik/sources/{self.slug}.svg") return UserSettingSerializer( data={ "title": self.name, @@ -202,7 +206,7 @@ class SAMLSource(Source): "authentik_sources_saml:login", kwargs={"source_slug": self.slug}, ), - "icon_url": static(f"authentik/sources/{self.slug}.svg"), + "icon_url": icon, } ) diff --git a/schema.yml b/schema.yml index c8e3886e9..a3a994254 100644 --- a/schema.yml +++ b/schema.yml @@ -15795,6 +15795,69 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /sources/all/{slug}/set_icon/: + post: + operationId: sources_all_set_icon_create + description: Set source icon + parameters: + - in: path + name: slug + schema: + type: string + description: Internal source name, used in URLs. + required: true + tags: + - sources + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/FileUploadRequest' + security: + - authentik: [] + responses: + '200': + description: Success + '400': + description: Bad request + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /sources/all/{slug}/set_icon_url/: + post: + operationId: sources_all_set_icon_url_create + description: Set source icon (as URL) + parameters: + - in: path + name: slug + schema: + type: string + description: Internal source name, used in URLs. + required: true + tags: + - sources + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FilePathRequest' + required: true + security: + - authentik: [] + responses: + '200': + description: Success + '400': + description: Bad request + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /sources/all/{slug}/used_by/: get: operationId: sources_all_used_by_list @@ -17092,20 +17155,6 @@ paths: description: Number of results to return per page. schema: type: integer - - in: query - name: pbm_uuid - schema: - type: string - format: uuid - - in: query - name: policies - schema: - type: array - items: - type: string - format: uuid - explode: true - style: form - in: query name: policy_engine_mode schema: @@ -17118,15 +17167,6 @@ paths: schema: type: string format: uuid - - in: query - name: property_mappings - schema: - type: array - items: - type: string - format: uuid - explode: true - style: form - name: search required: false in: query @@ -17176,10 +17216,6 @@ paths: - username_link description: How the source determines if an existing user should be authenticated or a new user enrolled. - - in: query - name: user_path_template - schema: - type: string tags: - sources security: @@ -28871,6 +28907,10 @@ components: readOnly: true user_path_template: type: string + icon: + type: string + nullable: true + readOnly: true server_uri: type: string format: uri @@ -28933,6 +28973,7 @@ components: required: - base_dn - component + - icon - managed - meta_model_name - name @@ -29662,6 +29703,10 @@ components: readOnly: true user_path_template: type: string + icon: + type: string + nullable: true + readOnly: true provider_type: $ref: '#/components/schemas/ProviderTypeEnum' request_token_url: @@ -29707,6 +29752,7 @@ components: - callback_url - component - consumer_key + - icon - managed - meta_model_name - name @@ -35075,6 +35121,10 @@ components: readOnly: true user_path_template: type: string + icon: + type: string + nullable: true + readOnly: true client_id: type: string description: Client identifier used to talk to Plex. @@ -35092,6 +35142,7 @@ components: description: Plex token used to check friends required: - component + - icon - managed - meta_model_name - name @@ -36495,6 +36546,10 @@ components: readOnly: true user_path_template: type: string + icon: + type: string + nullable: true + readOnly: true pre_authentication_flow: type: string format: uuid @@ -36543,6 +36598,7 @@ components: doesn''t log out manually. (Format: hours=1;minutes=2;seconds=3).' required: - component + - icon - managed - meta_model_name - name @@ -36936,8 +36992,13 @@ components: readOnly: true user_path_template: type: string + icon: + type: string + nullable: true + readOnly: true required: - component + - icon - managed - meta_model_name - name diff --git a/web/src/admin/sources/oauth/OAuthSourceForm.ts b/web/src/admin/sources/oauth/OAuthSourceForm.ts index 0cad08b7b..ae12a151b 100644 --- a/web/src/admin/sources/oauth/OAuthSourceForm.ts +++ b/web/src/admin/sources/oauth/OAuthSourceForm.ts @@ -1,5 +1,5 @@ import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/forms/FormGroup"; @@ -9,11 +9,12 @@ import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { t } from "@lingui/macro"; import { TemplateResult, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { until } from "lit/directives/until.js"; import { + CapabilitiesEnum, FlowsApi, FlowsInstancesListDesignationEnum, OAuthSource, @@ -57,6 +58,9 @@ export class OAuthSourceForm extends ModelForm { @property({ attribute: false }) providerType: SourceType | null = null; + @state() + clearIcon = false; + getSuccessMessage(): string { if (this.instance) { return t`Successfully updated source.`; @@ -65,18 +69,38 @@ export class OAuthSourceForm extends ModelForm { } } - send = (data: OAuthSource): Promise => { + send = async (data: OAuthSource): Promise => { data.providerType = (this.providerType?.slug || "") as ProviderTypeEnum; - if (this.instance?.slug) { - return new SourcesApi(DEFAULT_CONFIG).sourcesOauthPartialUpdate({ + let source: OAuthSource; + if (this.instance) { + source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthPartialUpdate({ slug: this.instance.slug, patchedOAuthSourceRequest: data, }); } else { - return new SourcesApi(DEFAULT_CONFIG).sourcesOauthCreate({ + source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthCreate({ oAuthSourceRequest: data as unknown as OAuthSourceRequest, }); } + const c = await config(); + if (c.capabilities.includes(CapabilitiesEnum.SaveMedia)) { + const icon = this.getFormFiles()["icon"]; + if (icon || this.clearIcon) { + await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ + slug: source.slug, + file: icon, + clear: this.clearIcon, + }); + } + } else { + await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconUrlCreate({ + slug: source.slug, + filePathRequest: { + url: data.icon || "", + }, + }); + } + return source; }; renderUrlOptions(): TemplateResult { @@ -282,6 +306,54 @@ export class OAuthSourceForm extends ModelForm { ${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}

+ ${until( + config().then((c) => { + if (c.capabilities.includes(CapabilitiesEnum.SaveMedia)) { + return html` + + ${this.instance?.icon + ? html` +

+ ${t`Currently set to:`} ${this.instance?.icon} +

+ ` + : html``} +
+ ${this.instance?.icon + ? html` + +
+ { + const target = ev.target as HTMLInputElement; + this.clearIcon = target.checked; + }} + /> + +
+

+ ${t`Delete currently set icon.`} +

+
+ ` + : html``}`; + } + return html` + +

+ ${t`Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test".`} +

+
`; + }), + )} ${t`Protocol settings`} diff --git a/web/src/admin/sources/plex/PlexSourceForm.ts b/web/src/admin/sources/plex/PlexSourceForm.ts index 9e1e35249..90b1acb51 100644 --- a/web/src/admin/sources/plex/PlexSourceForm.ts +++ b/web/src/admin/sources/plex/PlexSourceForm.ts @@ -1,5 +1,5 @@ import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { PlexAPIClient, PlexResource, popupCenterScreen } from "@goauthentik/common/helpers/plex"; import { first, randomString } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/FormGroup"; @@ -9,11 +9,12 @@ import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { t } from "@lingui/macro"; import { TemplateResult, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { until } from "lit/directives/until.js"; import { + CapabilitiesEnum, FlowsApi, FlowsInstancesListDesignationEnum, PlexSource, @@ -35,6 +36,9 @@ export class PlexSourceForm extends ModelForm { }); } + @state() + clearIcon = false; + @property() plexToken?: string; @@ -55,18 +59,38 @@ export class PlexSourceForm extends ModelForm { } } - send = (data: PlexSource): Promise => { + send = async (data: PlexSource): Promise => { data.plexToken = this.plexToken || ""; - if (this.instance?.slug) { - return new SourcesApi(DEFAULT_CONFIG).sourcesPlexUpdate({ + let source: PlexSource; + if (this.instance) { + source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexUpdate({ slug: this.instance.slug, plexSourceRequest: data, }); } else { - return new SourcesApi(DEFAULT_CONFIG).sourcesPlexCreate({ + source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexCreate({ plexSourceRequest: data, }); } + const c = await config(); + if (c.capabilities.includes(CapabilitiesEnum.SaveMedia)) { + const icon = this.getFormFiles()["icon"]; + if (icon || this.clearIcon) { + await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ + slug: source.slug, + file: icon, + clear: this.clearIcon, + }); + } + } else { + await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconUrlCreate({ + slug: source.slug, + filePathRequest: { + url: data.icon || "", + }, + }); + } + return source; }; async doAuth(): Promise { @@ -229,6 +253,54 @@ export class PlexSourceForm extends ModelForm { ${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}

+ ${until( + config().then((c) => { + if (c.capabilities.includes(CapabilitiesEnum.SaveMedia)) { + return html` + + ${this.instance?.icon + ? html` +

+ ${t`Currently set to:`} ${this.instance?.icon} +

+ ` + : html``} +
+ ${this.instance?.icon + ? html` + +
+ { + const target = ev.target as HTMLInputElement; + this.clearIcon = target.checked; + }} + /> + +
+

+ ${t`Delete currently set icon.`} +

+
+ ` + : html``}`; + } + return html` + +

+ ${t`Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test".`} +

+
`; + }), + )} ${t`Protocol settings`} diff --git a/web/src/admin/sources/saml/SAMLSourceForm.ts b/web/src/admin/sources/saml/SAMLSourceForm.ts index d760951aa..21d422efb 100644 --- a/web/src/admin/sources/saml/SAMLSourceForm.ts +++ b/web/src/admin/sources/saml/SAMLSourceForm.ts @@ -1,5 +1,5 @@ import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; @@ -9,12 +9,13 @@ import "@goauthentik/elements/utils/TimeDeltaHelp"; import { t } from "@lingui/macro"; import { TemplateResult, html } from "lit"; -import { customElement } from "lit/decorators.js"; +import { customElement, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { until } from "lit/directives/until.js"; import { BindingTypeEnum, + CapabilitiesEnum, CryptoApi, DigestAlgorithmEnum, FlowsApi, @@ -28,6 +29,9 @@ import { @customElement("ak-source-saml-form") export class SAMLSourceForm extends ModelForm { + @state() + clearIcon = false; + loadInstance(pk: string): Promise { return new SourcesApi(DEFAULT_CONFIG).sourcesSamlRetrieve({ slug: pk, @@ -42,17 +46,37 @@ export class SAMLSourceForm extends ModelForm { } } - send = (data: SAMLSource): Promise => { + send = async (data: SAMLSource): Promise => { + let source: SAMLSource; if (this.instance) { - return new SourcesApi(DEFAULT_CONFIG).sourcesSamlUpdate({ + source = await new SourcesApi(DEFAULT_CONFIG).sourcesSamlUpdate({ slug: this.instance.slug, sAMLSourceRequest: data, }); } else { - return new SourcesApi(DEFAULT_CONFIG).sourcesSamlCreate({ + source = await new SourcesApi(DEFAULT_CONFIG).sourcesSamlCreate({ sAMLSourceRequest: data, }); } + const c = await config(); + if (c.capabilities.includes(CapabilitiesEnum.SaveMedia)) { + const icon = this.getFormFiles()["icon"]; + if (icon || this.clearIcon) { + await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ + slug: source.slug, + file: icon, + clear: this.clearIcon, + }); + } + } else { + await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconUrlCreate({ + slug: source.slug, + filePathRequest: { + url: data.icon || "", + }, + }); + } + return source; }; renderForm(): TemplateResult { @@ -126,6 +150,54 @@ export class SAMLSourceForm extends ModelForm { + ${until( + config().then((c) => { + if (c.capabilities.includes(CapabilitiesEnum.SaveMedia)) { + return html` + + ${this.instance?.icon + ? html` +

+ ${t`Currently set to:`} ${this.instance?.icon} +

+ ` + : html``} +
+ ${this.instance?.icon + ? html` + +
+ { + const target = ev.target as HTMLInputElement; + this.clearIcon = target.checked; + }} + /> + +
+

+ ${t`Delete currently set icon.`} +

+
+ ` + : html``}`; + } + return html` + +

+ ${t`Either input a full URL, a relative path, or use 'fa://fa-test' to use the Font Awesome icon "fa-test".`} +

+
`; + }), + )} ${t`Protocol settings`}