Compare commits
3 Commits
workspace-
...
analytics
Author | SHA1 | Date | |
---|---|---|---|
435ba598bb | |||
582511abcc | |||
80ea1dae81 |
20
authentik/admin/analytics.py
Normal file
20
authentik/admin/analytics.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"""authentik admin analytics"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_description() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"worker_count": _("Number of running workers"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_data() -> dict[str, Any]:
|
||||||
|
worker_count = len(CELERY_APP.control.ping(timeout=0.5))
|
||||||
|
return {
|
||||||
|
"worker_count": worker_count,
|
||||||
|
}
|
0
authentik/analytics/__init__.py
Normal file
0
authentik/analytics/__init__.py
Normal file
54
authentik/analytics/api.py
Normal file
54
authentik/analytics/api.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""authentik analytics api"""
|
||||||
|
|
||||||
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
|
from rest_framework.fields import CharField, DictField
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.viewsets import ViewSet
|
||||||
|
|
||||||
|
from authentik.analytics.utils import get_analytics_data, get_analytics_description
|
||||||
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
from authentik.rbac.permissions import HasPermission
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsDescriptionSerializer(PassiveSerializer):
|
||||||
|
label = CharField()
|
||||||
|
desc = CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsDescriptionViewSet(ViewSet):
|
||||||
|
"""Read-only view of analytics descriptions"""
|
||||||
|
|
||||||
|
permission_classes = [HasPermission("authentik_rbac.view_system_settings")]
|
||||||
|
|
||||||
|
@extend_schema(responses={200: AnalyticsDescriptionSerializer})
|
||||||
|
def list(self, request: Request) -> Response:
|
||||||
|
"""Read-only view of analytics descriptions"""
|
||||||
|
data = []
|
||||||
|
for label, desc in get_analytics_description().items():
|
||||||
|
data.append({"label": label, "desc": desc})
|
||||||
|
return Response(AnalyticsDescriptionSerializer(data, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsDataViewSet(ViewSet):
|
||||||
|
"""Read-only view of analytics descriptions"""
|
||||||
|
|
||||||
|
permission_classes = [HasPermission("authentik_rbac.edit_system_settings")]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: inline_serializer(
|
||||||
|
name="AnalyticsData",
|
||||||
|
fields={
|
||||||
|
"data": DictField(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def list(self, request: Request) -> Response:
|
||||||
|
"""Read-only view of analytics descriptions"""
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"data": get_analytics_data(force=True),
|
||||||
|
}
|
||||||
|
)
|
12
authentik/analytics/apps.py
Normal file
12
authentik/analytics/apps.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"""authentik analytics app config"""
|
||||||
|
|
||||||
|
from authentik.blueprints.apps import ManagedAppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuthentikAdminConfig(ManagedAppConfig):
|
||||||
|
"""authentik analytics app config"""
|
||||||
|
|
||||||
|
name = "authentik.analytics"
|
||||||
|
label = "authentik_analytics"
|
||||||
|
verbose_name = "authentik Analytics"
|
||||||
|
default = True
|
19
authentik/analytics/models.py
Normal file
19
authentik/analytics/models.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""authentik analytics mixins"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsMixin:
|
||||||
|
@classmethod
|
||||||
|
def get_analytics_description(cls) -> dict[str, str]:
|
||||||
|
object_name = _(cls._meta.verbose_name)
|
||||||
|
count_desc = _("Number of {object_name} objects".format_map({"object_name": object_name}))
|
||||||
|
return {
|
||||||
|
"count": count_desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_analytics_data(cls) -> dict[str, Any]:
|
||||||
|
return {"count": cls.objects.all().count()}
|
17
authentik/analytics/settings.py
Normal file
17
authentik/analytics/settings.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
"""authentik admin settings"""
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"analytics_send": {
|
||||||
|
"task": "authentik.analytics.tasks.send_analytics",
|
||||||
|
"schedule": crontab(
|
||||||
|
minute=fqdn_rand("analytics_send"),
|
||||||
|
hour=fqdn_rand("analytics_send", stop=24),
|
||||||
|
day_of_week=fqdn_rand("analytics_send", 7),
|
||||||
|
),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
}
|
||||||
|
}
|
45
authentik/analytics/tasks.py
Normal file
45
authentik/analytics/tasks.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"""authentik admin tasks"""
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from requests import RequestException
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.analytics.utils import get_analytics_data
|
||||||
|
from authentik.events.models import Event, EventAction
|
||||||
|
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
|
||||||
|
from authentik.lib.utils.http import get_http_session
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task(bind=True, base=SystemTask)
|
||||||
|
@prefill_task
|
||||||
|
def send_analytics(self: SystemTask):
|
||||||
|
"""Send analytics"""
|
||||||
|
for tenant in Tenant.objects.filter(ready=True):
|
||||||
|
data = get_analytics_data(current_tenant=tenant)
|
||||||
|
if not tenant.analytics_enabled or not data:
|
||||||
|
self.set_status(TaskStatus.WARNING, "Analytics disabled. Nothing was sent.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
response = get_http_session().post(
|
||||||
|
"https://customers.goauthentik.io/api/analytics/post/", json=data
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
self.set_status(
|
||||||
|
TaskStatus.SUCCESSFUL,
|
||||||
|
"Successfully sent analytics",
|
||||||
|
orjson.dumps(
|
||||||
|
data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS | orjson.OPT_UTC_Z
|
||||||
|
).decode(),
|
||||||
|
)
|
||||||
|
Event.new(
|
||||||
|
EventAction.ANALYTICS_SENT,
|
||||||
|
message=_("Analytics sent"),
|
||||||
|
analytics_data=data,
|
||||||
|
).save()
|
||||||
|
except (RequestException, IndexError) as exc:
|
||||||
|
self.set_error(exc)
|
76
authentik/analytics/tests.py
Normal file
76
authentik/analytics/tests.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"""authentik analytics tests"""
|
||||||
|
|
||||||
|
from json import loads
|
||||||
|
from requests_mock import Mocker
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from authentik import __version__
|
||||||
|
from authentik.analytics.tasks import send_analytics
|
||||||
|
from authentik.analytics.utils import get_analytics_apps_data, get_analytics_apps_description, get_analytics_data, get_analytics_description, get_analytics_models_data, get_analytics_models_description
|
||||||
|
from authentik.core.models import Group, User
|
||||||
|
from authentik.events.models import Event, EventAction
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
|
from authentik.tenants.utils import get_current_tenant
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalytics(TestCase):
|
||||||
|
"""test analytics api"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.user = User.objects.create(username=generate_id())
|
||||||
|
self.group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||||
|
self.group.users.add(self.user)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.tenant = get_current_tenant()
|
||||||
|
|
||||||
|
def test_description_api(self):
|
||||||
|
"""Test Version API"""
|
||||||
|
response = self.client.get(reverse("authentik_api:analytics-description-list"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
loads(response.content)
|
||||||
|
|
||||||
|
def test_data_api(self):
|
||||||
|
"""Test Version API"""
|
||||||
|
response = self.client.get(reverse("authentik_api:analytics-data-list"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content)
|
||||||
|
self.assertEqual(body["data"]["version"], __version__)
|
||||||
|
|
||||||
|
def test_sending_enabled(self):
|
||||||
|
"""Test analytics sending"""
|
||||||
|
self.tenant.analytics_enabled = True
|
||||||
|
self.tenant.save()
|
||||||
|
with Mocker() as mocker:
|
||||||
|
mocker.post("https://customers.goauthentik.io/api/analytics/post/", status_code=200)
|
||||||
|
send_analytics.delay().get()
|
||||||
|
self.assertTrue(
|
||||||
|
Event.objects.filter(
|
||||||
|
action=EventAction.ANALYTICS_SENT
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sending_disabled(self):
|
||||||
|
"""Test analytics sending"""
|
||||||
|
self.tenant.analytics_enabled = False
|
||||||
|
self.tenant.save()
|
||||||
|
send_analytics.delay().get()
|
||||||
|
self.assertFalse(
|
||||||
|
Event.objects.filter(
|
||||||
|
action=EventAction.ANALYTICS_SENT
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_description_data_match_apps(self):
|
||||||
|
"""Test description and data keys match"""
|
||||||
|
description = get_analytics_apps_description()
|
||||||
|
data = get_analytics_apps_data()
|
||||||
|
self.assertEqual(data.keys(), description.keys())
|
||||||
|
|
||||||
|
def test_description_data_match_models(self):
|
||||||
|
"""Test description and data keys match"""
|
||||||
|
description = get_analytics_models_description()
|
||||||
|
data = get_analytics_models_data()
|
||||||
|
self.assertEqual(data.keys(), description.keys())
|
8
authentik/analytics/urls.py
Normal file
8
authentik/analytics/urls.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""API URLs"""
|
||||||
|
|
||||||
|
from authentik.analytics.api import AnalyticsDataViewSet, AnalyticsDescriptionViewSet
|
||||||
|
|
||||||
|
api_urlpatterns = [
|
||||||
|
("analytics/description", AnalyticsDescriptionViewSet, "analytics-description"),
|
||||||
|
("analytics/data", AnalyticsDataViewSet, "analytics-data"),
|
||||||
|
]
|
112
authentik/analytics/utils.py
Normal file
112
authentik/analytics/utils.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""authentik analytics utils"""
|
||||||
|
|
||||||
|
from hashlib import sha256
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from authentik import get_full_version
|
||||||
|
from authentik.analytics.models import AnalyticsMixin
|
||||||
|
from authentik.lib.utils.reflection import get_apps
|
||||||
|
from authentik.root.install_id import get_install_id
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
from authentik.tenants.utils import get_current_tenant
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_apps() -> dict:
|
||||||
|
modules = {}
|
||||||
|
for _authentik_app in get_apps():
|
||||||
|
try:
|
||||||
|
module = import_module(f"{_authentik_app.name}.analytics")
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
continue
|
||||||
|
except ImportError as exc:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Could not import app's analytics", app_name=_authentik_app.name, exc=exc
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not hasattr(module, "get_analytics_description") or not hasattr(
|
||||||
|
module, "get_analytics_data"
|
||||||
|
):
|
||||||
|
LOGGER.debug(
|
||||||
|
"App does not define API URLs",
|
||||||
|
app_name=_authentik_app.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
modules[_authentik_app.label] = module
|
||||||
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_apps_description() -> dict[str, str]:
|
||||||
|
result = {}
|
||||||
|
for app_label, module in get_analytics_apps().items():
|
||||||
|
for k, v in module.get_analytics_description().items():
|
||||||
|
result[f"{app_label}/app/{k}"] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_apps_data() -> dict[str, Any]:
|
||||||
|
result = {}
|
||||||
|
for app_label, module in get_analytics_apps().items():
|
||||||
|
for k, v in module.get_analytics_data().items():
|
||||||
|
result[f"{app_label}/app/{k}"] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_models() -> list[AnalyticsMixin]:
|
||||||
|
def get_subclasses(cls):
|
||||||
|
for subclass in cls.__subclasses__():
|
||||||
|
if subclass.__subclasses__():
|
||||||
|
yield from get_subclasses(subclass)
|
||||||
|
elif not subclass._meta.abstract:
|
||||||
|
yield subclass
|
||||||
|
|
||||||
|
return list(get_subclasses(AnalyticsMixin))
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_models_description() -> dict[str, str]:
|
||||||
|
result = {}
|
||||||
|
for model in get_analytics_models():
|
||||||
|
for k, v in model.get_analytics_description().items():
|
||||||
|
result[f"{model._meta.app_label}/models/{model._meta.object_name}/{k}"] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_models_data() -> dict[str, Any]:
|
||||||
|
result = {}
|
||||||
|
for model in get_analytics_models():
|
||||||
|
for k, v in model.get_analytics_data().items():
|
||||||
|
result[f"{model._meta.app_label}/models/{model._meta.object_name}/{k}"] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_description() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
**get_analytics_apps_description(),
|
||||||
|
**get_analytics_models_description(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_data(current_tenant: Tenant | None = None, force: bool = False) -> dict[str, Any]:
|
||||||
|
current_tenant = current_tenant or get_current_tenant()
|
||||||
|
if not current_tenant.analytics_enabled and not force:
|
||||||
|
return {}
|
||||||
|
data = {
|
||||||
|
**get_analytics_apps_data(),
|
||||||
|
**get_analytics_models_data(),
|
||||||
|
}
|
||||||
|
to_remove = []
|
||||||
|
for key in data.keys():
|
||||||
|
if key not in current_tenant.analytics_sources:
|
||||||
|
to_remove.append(key)
|
||||||
|
for key in to_remove:
|
||||||
|
del data[key]
|
||||||
|
return {
|
||||||
|
**data,
|
||||||
|
"install_id_hash": sha256(get_install_id().encode()).hexdigest(),
|
||||||
|
"tenant_hash": sha256(current_tenant.tenant_uuid.bytes).hexdigest(),
|
||||||
|
"version": get_full_version(),
|
||||||
|
}
|
@ -23,6 +23,7 @@ from model_utils.managers import InheritanceManager
|
|||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.analytics.models import AnalyticsMixin
|
||||||
from authentik.blueprints.models import ManagedModel
|
from authentik.blueprints.models import ManagedModel
|
||||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
||||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||||
@ -168,7 +169,7 @@ class GroupQuerySet(CTEQuerySet):
|
|||||||
return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte)
|
return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte)
|
||||||
|
|
||||||
|
|
||||||
class Group(SerializerModel, AttributesMixin):
|
class Group(SerializerModel, AttributesMixin, AnalyticsMixin):
|
||||||
"""Group model which supports a basic hierarchy and has attributes"""
|
"""Group model which supports a basic hierarchy and has attributes"""
|
||||||
|
|
||||||
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||||
@ -258,7 +259,7 @@ class UserManager(DjangoUserManager):
|
|||||||
return self.get_queryset().exclude_anonymous()
|
return self.get_queryset().exclude_anonymous()
|
||||||
|
|
||||||
|
|
||||||
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser, AnalyticsMixin):
|
||||||
"""authentik User model, based on django's contrib auth user model."""
|
"""authentik User model, based on django's contrib auth user model."""
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
|
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
|
||||||
@ -376,7 +377,7 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
|||||||
return get_avatar(self)
|
return get_avatar(self)
|
||||||
|
|
||||||
|
|
||||||
class Provider(SerializerModel):
|
class Provider(SerializerModel, AnalyticsMixin):
|
||||||
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
|
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
|
||||||
|
|
||||||
name = models.TextField(unique=True)
|
name = models.TextField(unique=True)
|
||||||
@ -470,7 +471,7 @@ class ApplicationQuerySet(QuerySet):
|
|||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class Application(SerializerModel, PolicyBindingModel):
|
class Application(SerializerModel, PolicyBindingModel, AnalyticsMixin):
|
||||||
"""Every Application which uses authentik for authentication/identification/authorization
|
"""Every Application which uses authentik for authentication/identification/authorization
|
||||||
needs an Application record. Other authentication types can subclass this Model to
|
needs an Application record. Other authentication types can subclass this Model to
|
||||||
add custom fields and other properties"""
|
add custom fields and other properties"""
|
||||||
@ -603,7 +604,7 @@ class SourceGroupMatchingModes(models.TextChoices):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
class Source(ManagedModel, SerializerModel, PolicyBindingModel, AnalyticsMixin):
|
||||||
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
|
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
|
||||||
|
|
||||||
name = models.TextField(help_text=_("Source's display Name."))
|
name = models.TextField(help_text=_("Source's display Name."))
|
||||||
@ -735,7 +736,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
class UserSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin):
|
||||||
"""Connection between User and Source."""
|
"""Connection between User and Source."""
|
||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
@ -755,7 +756,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
|||||||
unique_together = (("user", "source"),)
|
unique_together = (("user", "source"),)
|
||||||
|
|
||||||
|
|
||||||
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
|
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel, AnalyticsMixin):
|
||||||
"""Connection between Group and Source."""
|
"""Connection between Group and Source."""
|
||||||
|
|
||||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||||
@ -879,7 +880,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
|
|||||||
).save()
|
).save()
|
||||||
|
|
||||||
|
|
||||||
class PropertyMapping(SerializerModel, ManagedModel):
|
class PropertyMapping(SerializerModel, ManagedModel, AnalyticsMixin):
|
||||||
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
|
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
|
||||||
|
|
||||||
pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||||
|
49
authentik/events/migrations/0008_alter_event_action.py
Normal file
49
authentik/events/migrations/0008_alter_event_action.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Generated by Django 5.0.9 on 2024-09-25 11:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_events", "0007_event_authentik_e_action_9a9dd9_idx_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="event",
|
||||||
|
name="action",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("login", "Login"),
|
||||||
|
("login_failed", "Login Failed"),
|
||||||
|
("logout", "Logout"),
|
||||||
|
("user_write", "User Write"),
|
||||||
|
("suspicious_request", "Suspicious Request"),
|
||||||
|
("password_set", "Password Set"),
|
||||||
|
("secret_view", "Secret View"),
|
||||||
|
("secret_rotate", "Secret Rotate"),
|
||||||
|
("invitation_used", "Invite Used"),
|
||||||
|
("authorize_application", "Authorize Application"),
|
||||||
|
("source_linked", "Source Linked"),
|
||||||
|
("impersonation_started", "Impersonation Started"),
|
||||||
|
("impersonation_ended", "Impersonation Ended"),
|
||||||
|
("flow_execution", "Flow Execution"),
|
||||||
|
("policy_execution", "Policy Execution"),
|
||||||
|
("policy_exception", "Policy Exception"),
|
||||||
|
("property_mapping_exception", "Property Mapping Exception"),
|
||||||
|
("system_task_execution", "System Task Execution"),
|
||||||
|
("system_task_exception", "System Task Exception"),
|
||||||
|
("system_exception", "System Exception"),
|
||||||
|
("configuration_error", "Configuration Error"),
|
||||||
|
("model_created", "Model Created"),
|
||||||
|
("model_updated", "Model Updated"),
|
||||||
|
("model_deleted", "Model Deleted"),
|
||||||
|
("email_sent", "Email Sent"),
|
||||||
|
("analytics_sent", "Analytics Sent"),
|
||||||
|
("update_available", "Update Available"),
|
||||||
|
("custom_", "Custom Prefix"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -119,6 +119,7 @@ class EventAction(models.TextChoices):
|
|||||||
MODEL_DELETED = "model_deleted"
|
MODEL_DELETED = "model_deleted"
|
||||||
EMAIL_SENT = "email_sent"
|
EMAIL_SENT = "email_sent"
|
||||||
|
|
||||||
|
ANALYTICS_SENT = "analytics_sent"
|
||||||
UPDATE_AVAILABLE = "update_available"
|
UPDATE_AVAILABLE = "update_available"
|
||||||
|
|
||||||
CUSTOM_PREFIX = "custom_"
|
CUSTOM_PREFIX = "custom_"
|
||||||
|
@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.0.9 on 2024-09-25 11:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_policies_event_matcher", "0023_alter_eventmatcherpolicy_action_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="eventmatcherpolicy",
|
||||||
|
name="action",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[
|
||||||
|
("login", "Login"),
|
||||||
|
("login_failed", "Login Failed"),
|
||||||
|
("logout", "Logout"),
|
||||||
|
("user_write", "User Write"),
|
||||||
|
("suspicious_request", "Suspicious Request"),
|
||||||
|
("password_set", "Password Set"),
|
||||||
|
("secret_view", "Secret View"),
|
||||||
|
("secret_rotate", "Secret Rotate"),
|
||||||
|
("invitation_used", "Invite Used"),
|
||||||
|
("authorize_application", "Authorize Application"),
|
||||||
|
("source_linked", "Source Linked"),
|
||||||
|
("impersonation_started", "Impersonation Started"),
|
||||||
|
("impersonation_ended", "Impersonation Ended"),
|
||||||
|
("flow_execution", "Flow Execution"),
|
||||||
|
("policy_execution", "Policy Execution"),
|
||||||
|
("policy_exception", "Policy Exception"),
|
||||||
|
("property_mapping_exception", "Property Mapping Exception"),
|
||||||
|
("system_task_execution", "System Task Execution"),
|
||||||
|
("system_task_exception", "System Task Exception"),
|
||||||
|
("system_exception", "System Exception"),
|
||||||
|
("configuration_error", "Configuration Error"),
|
||||||
|
("model_created", "Model Created"),
|
||||||
|
("model_updated", "Model Updated"),
|
||||||
|
("model_deleted", "Model Deleted"),
|
||||||
|
("email_sent", "Email Sent"),
|
||||||
|
("analytics_sent", "Analytics Sent"),
|
||||||
|
("update_available", "Update Available"),
|
||||||
|
("custom_", "Custom Prefix"),
|
||||||
|
],
|
||||||
|
default=None,
|
||||||
|
help_text="Match created events with this action type. When left empty, all action types will be matched.",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -70,6 +70,7 @@ TENANT_APPS = [
|
|||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"authentik.admin",
|
"authentik.admin",
|
||||||
|
"authentik.analytics",
|
||||||
"authentik.api",
|
"authentik.api",
|
||||||
"authentik.crypto",
|
"authentik.crypto",
|
||||||
"authentik.flows",
|
"authentik.flows",
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
"""Serializer for tenants models"""
|
"""Serializer for tenants models"""
|
||||||
|
|
||||||
from django_tenants.utils import get_public_schema_name
|
from django_tenants.utils import get_public_schema_name
|
||||||
|
from rest_framework.fields import SerializerMethodField
|
||||||
from rest_framework.generics import RetrieveUpdateAPIView
|
from rest_framework.generics import RetrieveUpdateAPIView
|
||||||
from rest_framework.permissions import SAFE_METHODS
|
from rest_framework.permissions import SAFE_METHODS
|
||||||
|
|
||||||
|
from authentik.analytics.api import AnalyticsDescriptionSerializer
|
||||||
|
from authentik.analytics.utils import get_analytics_description
|
||||||
from authentik.core.api.utils import ModelSerializer
|
from authentik.core.api.utils import ModelSerializer
|
||||||
from authentik.rbac.permissions import HasPermission
|
from authentik.rbac.permissions import HasPermission
|
||||||
from authentik.tenants.models import Tenant
|
from authentik.tenants.models import Tenant
|
||||||
@ -12,6 +15,8 @@ from authentik.tenants.models import Tenant
|
|||||||
class SettingsSerializer(ModelSerializer):
|
class SettingsSerializer(ModelSerializer):
|
||||||
"""Settings Serializer"""
|
"""Settings Serializer"""
|
||||||
|
|
||||||
|
analytics_sources_obj = SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tenant
|
model = Tenant
|
||||||
fields = [
|
fields = [
|
||||||
@ -25,8 +30,19 @@ class SettingsSerializer(ModelSerializer):
|
|||||||
"impersonation",
|
"impersonation",
|
||||||
"default_token_duration",
|
"default_token_duration",
|
||||||
"default_token_length",
|
"default_token_length",
|
||||||
|
"default_token_length",
|
||||||
|
"analytics_enabled",
|
||||||
|
"analytics_sources",
|
||||||
|
"analytics_sources_obj",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_analytics_sources_obj(self, obj: Tenant) -> list[AnalyticsDescriptionSerializer]:
|
||||||
|
result = []
|
||||||
|
for label, desc in get_analytics_description().items():
|
||||||
|
if label in obj.analytics_sources:
|
||||||
|
result.append((label, desc))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class SettingsView(RetrieveUpdateAPIView):
|
class SettingsView(RetrieveUpdateAPIView):
|
||||||
"""Settings view"""
|
"""Settings view"""
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.0.9 on 2024-09-24 15:36
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_tenants", "0003_alter_tenant_default_token_duration"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="tenant",
|
||||||
|
name="analytics_enabled",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="tenant",
|
||||||
|
name="analytics_sources",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(), blank=True, default=list, size=None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -4,6 +4,7 @@ import re
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -96,6 +97,9 @@ class Tenant(TenantMixin, SerializerModel):
|
|||||||
validators=[MinValueValidator(1)],
|
validators=[MinValueValidator(1)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
analytics_enabled = models.BooleanField(default=False)
|
||||||
|
analytics_sources = ArrayField(models.TextField(), blank=True, default=list)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.schema_name == "template":
|
if self.schema_name == "template":
|
||||||
raise IntegrityError("Cannot create schema named template")
|
raise IntegrityError("Cannot create schema named template")
|
||||||
|
@ -4227,6 +4227,7 @@
|
|||||||
"model_updated",
|
"model_updated",
|
||||||
"model_deleted",
|
"model_deleted",
|
||||||
"email_sent",
|
"email_sent",
|
||||||
|
"analytics_sent",
|
||||||
"update_available",
|
"update_available",
|
||||||
"custom_"
|
"custom_"
|
||||||
],
|
],
|
||||||
@ -4251,6 +4252,7 @@
|
|||||||
null,
|
null,
|
||||||
"authentik.tenants",
|
"authentik.tenants",
|
||||||
"authentik.admin",
|
"authentik.admin",
|
||||||
|
"authentik.analytics",
|
||||||
"authentik.api",
|
"authentik.api",
|
||||||
"authentik.crypto",
|
"authentik.crypto",
|
||||||
"authentik.flows",
|
"authentik.flows",
|
||||||
@ -13116,6 +13118,7 @@
|
|||||||
"model_updated",
|
"model_updated",
|
||||||
"model_deleted",
|
"model_deleted",
|
||||||
"email_sent",
|
"email_sent",
|
||||||
|
"analytics_sent",
|
||||||
"update_available",
|
"update_available",
|
||||||
"custom_"
|
"custom_"
|
||||||
],
|
],
|
||||||
@ -13277,6 +13280,7 @@
|
|||||||
"model_updated",
|
"model_updated",
|
||||||
"model_deleted",
|
"model_deleted",
|
||||||
"email_sent",
|
"email_sent",
|
||||||
|
"analytics_sent",
|
||||||
"update_available",
|
"update_available",
|
||||||
"custom_"
|
"custom_"
|
||||||
],
|
],
|
||||||
|
111
schema.yml
111
schema.yml
@ -290,6 +290,64 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/GenericError'
|
$ref: '#/components/schemas/GenericError'
|
||||||
description: ''
|
description: ''
|
||||||
|
/analytics/data/:
|
||||||
|
get:
|
||||||
|
operationId: analytics_data_list
|
||||||
|
description: Read-only view of analytics descriptions
|
||||||
|
tags:
|
||||||
|
- analytics
|
||||||
|
security:
|
||||||
|
- authentik: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AnalyticsData'
|
||||||
|
description: ''
|
||||||
|
'400':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ValidationError'
|
||||||
|
description: ''
|
||||||
|
'403':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenericError'
|
||||||
|
description: ''
|
||||||
|
/analytics/description/:
|
||||||
|
get:
|
||||||
|
operationId: analytics_description_list
|
||||||
|
description: Read-only view of analytics descriptions
|
||||||
|
tags:
|
||||||
|
- analytics
|
||||||
|
security:
|
||||||
|
- authentik: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AnalyticsDescription'
|
||||||
|
description: ''
|
||||||
|
'400':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ValidationError'
|
||||||
|
description: ''
|
||||||
|
'403':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenericError'
|
||||||
|
description: ''
|
||||||
/authenticators/admin/all/:
|
/authenticators/admin/all/:
|
||||||
get:
|
get:
|
||||||
operationId: authenticators_admin_all_list
|
operationId: authenticators_admin_all_list
|
||||||
@ -11502,6 +11560,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
enum:
|
enum:
|
||||||
|
- analytics_sent
|
||||||
- authorize_application
|
- authorize_application
|
||||||
- configuration_error
|
- configuration_error
|
||||||
- custom_
|
- custom_
|
||||||
@ -35758,6 +35817,25 @@ components:
|
|||||||
- rsa
|
- rsa
|
||||||
- ecdsa
|
- ecdsa
|
||||||
type: string
|
type: string
|
||||||
|
AnalyticsData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: {}
|
||||||
|
required:
|
||||||
|
- data
|
||||||
|
AnalyticsDescription:
|
||||||
|
type: object
|
||||||
|
description: Base serializer class which doesn't implement create/update methods
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
desc:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- desc
|
||||||
|
- label
|
||||||
App:
|
App:
|
||||||
type: object
|
type: object
|
||||||
description: Serialize Application info
|
description: Serialize Application info
|
||||||
@ -35773,6 +35851,7 @@ components:
|
|||||||
enum:
|
enum:
|
||||||
- authentik.tenants
|
- authentik.tenants
|
||||||
- authentik.admin
|
- authentik.admin
|
||||||
|
- authentik.analytics
|
||||||
- authentik.api
|
- authentik.api
|
||||||
- authentik.crypto
|
- authentik.crypto
|
||||||
- authentik.flows
|
- authentik.flows
|
||||||
@ -38960,6 +39039,7 @@ components:
|
|||||||
- model_updated
|
- model_updated
|
||||||
- model_deleted
|
- model_deleted
|
||||||
- email_sent
|
- email_sent
|
||||||
|
- analytics_sent
|
||||||
- update_available
|
- update_available
|
||||||
- custom_
|
- custom_
|
||||||
type: string
|
type: string
|
||||||
@ -47361,6 +47441,13 @@ components:
|
|||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: 1
|
minimum: 1
|
||||||
description: Default token length
|
description: Default token length
|
||||||
|
analytics_enabled:
|
||||||
|
type: boolean
|
||||||
|
analytics_sources:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
PatchedSourceStageRequest:
|
PatchedSourceStageRequest:
|
||||||
type: object
|
type: object
|
||||||
description: SourceStage Serializer
|
description: SourceStage Serializer
|
||||||
@ -51001,6 +51088,23 @@ components:
|
|||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: 1
|
minimum: 1
|
||||||
description: Default token length
|
description: Default token length
|
||||||
|
analytics_enabled:
|
||||||
|
type: boolean
|
||||||
|
analytics_sources:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
analytics_sources_obj:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 2
|
||||||
|
readOnly: true
|
||||||
|
required:
|
||||||
|
- analytics_sources_obj
|
||||||
SettingsRequest:
|
SettingsRequest:
|
||||||
type: object
|
type: object
|
||||||
description: Settings Serializer
|
description: Settings Serializer
|
||||||
@ -51041,6 +51145,13 @@ components:
|
|||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: 1
|
minimum: 1
|
||||||
description: Default token length
|
description: Default token length
|
||||||
|
analytics_enabled:
|
||||||
|
type: boolean
|
||||||
|
analytics_sources:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
SeverityEnum:
|
SeverityEnum:
|
||||||
enum:
|
enum:
|
||||||
- notice
|
- notice
|
||||||
|
@ -5,6 +5,8 @@ import "@goauthentik/components/ak-switch-input";
|
|||||||
import "@goauthentik/components/ak-text-input";
|
import "@goauthentik/components/ak-text-input";
|
||||||
import "@goauthentik/elements/CodeMirror";
|
import "@goauthentik/elements/CodeMirror";
|
||||||
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
|
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
|
||||||
|
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
|
||||||
|
import { DataProvision, DualSelectPair } from "@goauthentik/elements/ak-dual-select/types";
|
||||||
import { Form } from "@goauthentik/elements/forms/Form";
|
import { Form } from "@goauthentik/elements/forms/Form";
|
||||||
import "@goauthentik/elements/forms/FormGroup";
|
import "@goauthentik/elements/forms/FormGroup";
|
||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||||
@ -19,7 +21,17 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
|||||||
|
|
||||||
import PFList from "@patternfly/patternfly/components/List/list.css";
|
import PFList from "@patternfly/patternfly/components/List/list.css";
|
||||||
|
|
||||||
import { AdminApi, Settings, SettingsRequest } from "@goauthentik/api";
|
import {
|
||||||
|
AdminApi,
|
||||||
|
AnalyticsApi,
|
||||||
|
AnalyticsDescription,
|
||||||
|
Settings,
|
||||||
|
SettingsRequest,
|
||||||
|
} from "@goauthentik/api";
|
||||||
|
|
||||||
|
function analyticsSourceToPair(analyticsSource: AnalyticsDescription): DualSelectPair {
|
||||||
|
return [analyticsSource.label, analyticsSource.desc];
|
||||||
|
}
|
||||||
|
|
||||||
@customElement("ak-admin-settings-form")
|
@customElement("ak-admin-settings-form")
|
||||||
export class AdminSettingsForm extends Form<SettingsRequest> {
|
export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||||
@ -211,6 +223,42 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
|||||||
value="${first(this._settings?.defaultTokenLength, 60)}"
|
value="${first(this._settings?.defaultTokenLength, 60)}"
|
||||||
help=${msg("Default length of generated tokens")}
|
help=${msg("Default length of generated tokens")}
|
||||||
></ak-number-input>
|
></ak-number-input>
|
||||||
|
<ak-form-group ?expanded=${false}>
|
||||||
|
<span slot="header"> ${msg("Analytics")} </span>
|
||||||
|
<div slot="body" class="pf-c-form">
|
||||||
|
<ak-switch-input
|
||||||
|
name="analyticsEnabled"
|
||||||
|
label=${msg("Enable analytics")}
|
||||||
|
?checked="${this._settings?.analyticsEnabled}"
|
||||||
|
help=${msg("Enable sending analytics to the authentik team")}
|
||||||
|
>
|
||||||
|
</ak-switch-input>
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${msg("Analytics sources")}
|
||||||
|
name="analyticsSources"
|
||||||
|
>
|
||||||
|
<ak-dual-select-provider
|
||||||
|
.provider=${async (
|
||||||
|
_page: number,
|
||||||
|
_search?: string,
|
||||||
|
): Promise<DataProvision> => {
|
||||||
|
return new AnalyticsApi(DEFAULT_CONFIG)
|
||||||
|
.analyticsDescriptionList()
|
||||||
|
.then((results) => {
|
||||||
|
return {
|
||||||
|
options: results.map(analyticsSourceToPair),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
.selected=${this._settings?.analyticsSourcesObj ?? []}
|
||||||
|
available-label="${msg("Available sources")}"
|
||||||
|
selected-label="${msg("Selected sources")}"
|
||||||
|
>
|
||||||
|
</ak-dual-select-provider>
|
||||||
|
<p class="pf-c-form__helper-text">${msg("Choose what data to send")}</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
</div>
|
||||||
|
</ak-form-group>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user