core: user paths (#3085)
* init Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add user_path_template Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add to sources and flow Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add outposts & api Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * dark theme for treeview Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add search Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add docs and tests for validation Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add to user write stage Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add web ui Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: improve error handling Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
c4b4c7134d
commit
1c62a3db6e
2
Makefile
2
Makefile
|
@ -106,7 +106,7 @@ run:
|
|||
web-build: web-install
|
||||
cd web && npm run build
|
||||
|
||||
web: web-lint-fix web-lint web-extract
|
||||
web: web-lint-fix web-lint
|
||||
|
||||
web-install:
|
||||
cd web && npm ci
|
||||
|
|
|
@ -53,6 +53,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
|||
"policy_engine_mode",
|
||||
"user_matching_mode",
|
||||
"managed",
|
||||
"user_path_template",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ from drf_spectacular.utils import (
|
|||
)
|
||||
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, JSONField, SerializerMethodField
|
||||
from rest_framework.fields import CharField, JSONField, ListField, SerializerMethodField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import (
|
||||
|
@ -50,6 +50,7 @@ from authentik.core.middleware import (
|
|||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_SA,
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||
USER_PATH_SERVICE_ACCOUNT,
|
||||
Group,
|
||||
Token,
|
||||
TokenIntents,
|
||||
|
@ -77,6 +78,15 @@ class UserSerializer(ModelSerializer):
|
|||
uid = CharField(read_only=True)
|
||||
username = CharField(max_length=150)
|
||||
|
||||
def validate_path(self, path: str) -> str:
|
||||
"""Validate path"""
|
||||
if path[:1] == "/" or path[-1] == "/":
|
||||
raise ValidationError(_("No leading or trailing slashes allowed."))
|
||||
for segment in path.split("/"):
|
||||
if segment == "":
|
||||
raise ValidationError(_("No empty segments in user path allowed."))
|
||||
return path
|
||||
|
||||
class Meta:
|
||||
|
||||
model = User
|
||||
|
@ -93,6 +103,7 @@ class UserSerializer(ModelSerializer):
|
|||
"avatar",
|
||||
"attributes",
|
||||
"uid",
|
||||
"path",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"name": {"allow_blank": True},
|
||||
|
@ -208,6 +219,11 @@ class UsersFilter(FilterSet):
|
|||
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
|
||||
uuid = CharFilter(field_name="uuid")
|
||||
|
||||
path = CharFilter(
|
||||
field_name="path",
|
||||
)
|
||||
path_startswith = CharFilter(field_name="path", lookup_expr="startswith")
|
||||
|
||||
groups_by_name = ModelMultipleChoiceFilter(
|
||||
field_name="ak_groups__name",
|
||||
to_field_name="name",
|
||||
|
@ -314,6 +330,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||
username=username,
|
||||
name=username,
|
||||
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
|
||||
path=USER_PATH_SERVICE_ACCOUNT,
|
||||
)
|
||||
if create_group and self.request.user.has_perm("authentik_core.add_group"):
|
||||
group = Group.objects.create(
|
||||
|
@ -464,3 +481,32 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||
if self.request.user.has_perm("authentik_core.view_user"):
|
||||
return self._filter_queryset_for_list(queryset)
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
"UserPathSerializer", {"paths": ListField(child=CharField(), read_only=True)}
|
||||
)
|
||||
},
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="search",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
)
|
||||
],
|
||||
)
|
||||
@action(detail=False, pagination_class=None)
|
||||
def paths(self, request: Request) -> Response:
|
||||
"""Get all user paths"""
|
||||
return Response(
|
||||
data={
|
||||
"paths": list(
|
||||
self.filter_queryset(self.get_queryset())
|
||||
.values("path")
|
||||
.distinct()
|
||||
.order_by("path")
|
||||
.values_list("path", flat=True)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -12,9 +12,9 @@ import authentik.core.models
|
|||
|
||||
|
||||
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
# We have to use a direct import here, otherwise we get an object manager error
|
||||
from authentik.core.models import User
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||
|
@ -28,9 +28,9 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|||
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
|
||||
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
|
||||
if password:
|
||||
akadmin.set_password(password, signal=False)
|
||||
akadmin.password = make_password(password)
|
||||
else:
|
||||
akadmin.set_unusable_password()
|
||||
akadmin.password = make_password(None)
|
||||
akadmin.save()
|
||||
|
||||
|
||||
|
|
|
@ -8,9 +8,9 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|||
|
||||
|
||||
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
# We have to use a direct import here, otherwise we get an object manager error
|
||||
from authentik.core.models import User
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||
|
@ -24,9 +24,9 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|||
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
|
||||
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
|
||||
if password:
|
||||
akadmin.set_password(password, signal=False)
|
||||
akadmin.password = make_password(password)
|
||||
else:
|
||||
akadmin.set_unusable_password()
|
||||
akadmin.password = make_password(None)
|
||||
akadmin.save()
|
||||
|
||||
|
||||
|
|
23
authentik/core/migrations/0021_source_user_path_user_path.py
Normal file
23
authentik/core/migrations/0021_source_user_path_user_path.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 4.0.5 on 2022-06-13 18:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0020_application_open_in_new_tab"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="source",
|
||||
name="user_path_template",
|
||||
field=models.TextField(default="goauthentik.io/sources/%(slug)s"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="path",
|
||||
field=models.TextField(default="users"),
|
||||
),
|
||||
]
|
|
@ -46,6 +46,9 @@ USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
|
|||
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
|
||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
||||
|
||||
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
|
||||
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
|
||||
|
||||
GRAVATAR_URL = "https://secure.gravatar.com"
|
||||
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
|
||||
|
||||
|
@ -138,6 +141,7 @@ class User(GuardianUserMixin, AbstractUser):
|
|||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False)
|
||||
name = models.TextField(help_text=_("User's display name."))
|
||||
path = models.TextField(default="users")
|
||||
|
||||
sources = models.ManyToManyField("Source", through="UserSourceConnection")
|
||||
ak_groups = models.ManyToManyField("Group", related_name="users")
|
||||
|
@ -147,6 +151,11 @@ class User(GuardianUserMixin, AbstractUser):
|
|||
|
||||
objects = UserManager()
|
||||
|
||||
@staticmethod
|
||||
def default_path() -> str:
|
||||
"""Get the default user path"""
|
||||
return User._meta.get_field("path").default
|
||||
|
||||
def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
|
||||
"""Get a dictionary containing the attributes from all groups the user belongs to,
|
||||
including the users attributes"""
|
||||
|
@ -373,6 +382,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||
name = models.TextField(help_text=_("Source's display Name."))
|
||||
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
|
||||
|
||||
user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s")
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
|
||||
|
||||
|
@ -408,6 +419,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||
|
||||
objects = InheritanceManager()
|
||||
|
||||
def get_user_path(self) -> str:
|
||||
"""Get user path, fallback to default for formatting errors"""
|
||||
try:
|
||||
return self.user_path_template % {
|
||||
"slug": self.slug,
|
||||
}
|
||||
# pylint: disable=broad-except
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Failed to template user path", exc=exc, source=self)
|
||||
return User.default_path()
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
"""Return component used to edit this object"""
|
||||
|
|
|
@ -31,6 +31,7 @@ from authentik.policies.utils import delete_none_keys
|
|||
from authentik.stages.password import BACKEND_INBUILT
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
|
||||
|
||||
|
||||
class Action(Enum):
|
||||
|
@ -291,5 +292,6 @@ class SourceFlowManager:
|
|||
connection,
|
||||
**{
|
||||
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
|
||||
PLAN_CONTEXT_USER_PATH: self.source.get_user_path(),
|
||||
},
|
||||
)
|
||||
|
|
|
@ -5,7 +5,7 @@ from rest_framework.test import APITestCase
|
|||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||
from authentik.flows.models import FlowDesignation
|
||||
from authentik.lib.generators import generate_key
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
@ -149,3 +149,65 @@ class TestUsersAPI(APITestCase):
|
|||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_paths(self):
|
||||
"""Test path"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-paths"),
|
||||
)
|
||||
print(response.content)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(response.content.decode(), {"paths": ["users"]})
|
||||
|
||||
def test_path_valid(self):
|
||||
"""Test path"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
def test_path_invalid(self):
|
||||
"""Test path (invalid)"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "/foo"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
|
||||
)
|
||||
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": ""},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(response.content.decode(), {"path": ["This field may not be blank."]})
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo/"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"username": generate_id(),
|
||||
"groups": [],
|
||||
"path": "fos//o",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(), {"path": ["No empty segments in user path allowed."]}
|
||||
)
|
||||
|
|
|
@ -20,6 +20,7 @@ from authentik import __version__, get_build_hash
|
|||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP,
|
||||
USER_ATTRIBUTE_SA,
|
||||
USER_PATH_SYSTEM_PREFIX,
|
||||
Provider,
|
||||
Token,
|
||||
TokenIntents,
|
||||
|
@ -39,6 +40,8 @@ OUR_VERSION = parse(__version__)
|
|||
OUTPOST_HELLO_INTERVAL = 10
|
||||
LOGGER = get_logger()
|
||||
|
||||
USER_PATH_OUTPOSTS = USER_PATH_SYSTEM_PREFIX + "/outposts"
|
||||
|
||||
|
||||
class ServiceConnectionInvalid(SentryIgnoredException):
|
||||
"""Exception raised when a Service Connection has invalid parameters"""
|
||||
|
@ -339,6 +342,7 @@ class Outpost(ManagedModel):
|
|||
user.attributes[USER_ATTRIBUTE_SA] = True
|
||||
user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
|
||||
user.name = f"Outpost {self.name} Service-Account"
|
||||
user.path = USER_PATH_OUTPOSTS
|
||||
user.save()
|
||||
if should_create_user:
|
||||
self.build_user_permissions(user)
|
||||
|
|
|
@ -64,7 +64,9 @@ class BaseLDAPSynchronizer:
|
|||
|
||||
def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]:
|
||||
"""Build attributes for User object based on property mappings."""
|
||||
return self._build_object_properties(user_dn, self._source.property_mappings, **kwargs)
|
||||
props = self._build_object_properties(user_dn, self._source.property_mappings, **kwargs)
|
||||
props["path"] = self._source.get_user_path()
|
||||
return props
|
||||
|
||||
def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]:
|
||||
"""Build attributes for Group object based on property mappings."""
|
||||
|
|
|
@ -146,6 +146,7 @@ class ResponseProcessor:
|
|||
USER_ATTRIBUTE_DELETE_ON_LOGOUT: True,
|
||||
USER_ATTRIBUTE_EXPIRES: expiry,
|
||||
},
|
||||
path=self._source.get_user_path(),
|
||||
)
|
||||
LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
|
||||
user.set_unusable_password()
|
||||
|
|
|
@ -12,7 +12,11 @@ class UserWriteStageSerializer(StageSerializer):
|
|||
class Meta:
|
||||
|
||||
model = UserWriteStage
|
||||
fields = StageSerializer.Meta.fields + ["create_users_as_inactive", "create_users_group"]
|
||||
fields = StageSerializer.Meta.fields + [
|
||||
"create_users_as_inactive",
|
||||
"create_users_group",
|
||||
"user_path_template",
|
||||
]
|
||||
|
||||
|
||||
class UserWriteStageViewSet(UsedByMixin, ModelViewSet):
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.0.5 on 2022-06-14 20:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_user_write", "0004_userwritestage_create_users_group"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userwritestage",
|
||||
name="user_path_template",
|
||||
field=models.TextField(default="", blank=True),
|
||||
),
|
||||
]
|
|
@ -26,6 +26,11 @@ class UserWriteStage(Stage):
|
|||
help_text=_("Optionally add newly created users to this group."),
|
||||
)
|
||||
|
||||
user_path_template = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def serializer(self) -> BaseSerializer:
|
||||
from authentik.stages.user_write.api import UserWriteStageSerializer
|
||||
|
|
|
@ -19,6 +19,7 @@ from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
|||
from authentik.stages.user_write.signals import user_write
|
||||
|
||||
PLAN_CONTEXT_GROUPS = "groups"
|
||||
PLAN_CONTEXT_USER_PATH = "user_path"
|
||||
|
||||
|
||||
class UserWriteStageView(StageView):
|
||||
|
@ -49,9 +50,15 @@ class UserWriteStageView(StageView):
|
|||
def ensure_user(self) -> tuple[User, bool]:
|
||||
"""Ensure a user exists"""
|
||||
user_created = False
|
||||
path = self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_USER_PATH, self.executor.current_stage.user_path_template
|
||||
)
|
||||
if path == "":
|
||||
path = User.default_path()
|
||||
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User(
|
||||
is_active=not self.executor.current_stage.create_users_as_inactive
|
||||
is_active=not self.executor.current_stage.create_users_as_inactive,
|
||||
path=path,
|
||||
)
|
||||
self.executor.plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT
|
||||
self.logger.debug(
|
||||
|
|
101
schema.yml
101
schema.yml
|
@ -3067,6 +3067,14 @@ paths:
|
|||
description: Number of results to return per page.
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: path
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: path_startswith
|
||||
schema:
|
||||
type: string
|
||||
- name: search
|
||||
required: false
|
||||
in: query
|
||||
|
@ -3390,6 +3398,30 @@ paths:
|
|||
$ref: '#/components/schemas/ValidationError'
|
||||
'403':
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
/core/users/paths/:
|
||||
get:
|
||||
operationId: core_users_paths_retrieve
|
||||
description: Get all user paths
|
||||
parameters:
|
||||
- in: query
|
||||
name: search
|
||||
schema:
|
||||
type: string
|
||||
tags:
|
||||
- core
|
||||
security:
|
||||
- authentik: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserPath'
|
||||
description: ''
|
||||
'400':
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
'403':
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
/core/users/service_account/:
|
||||
post:
|
||||
operationId: core_users_service_account_create
|
||||
|
@ -13133,6 +13165,10 @@ 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:
|
||||
|
@ -18826,6 +18862,10 @@ paths:
|
|||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: user_path_template
|
||||
schema:
|
||||
type: string
|
||||
tags:
|
||||
- stages
|
||||
security:
|
||||
|
@ -22705,6 +22745,8 @@ components:
|
|||
can be overwritten by migrations. You can still modify the objects via
|
||||
the API, but expect changes to be overwritten in a later update.
|
||||
readOnly: true
|
||||
user_path_template:
|
||||
type: string
|
||||
server_uri:
|
||||
type: string
|
||||
format: uri
|
||||
|
@ -22808,6 +22850,9 @@ components:
|
|||
- $ref: '#/components/schemas/UserMatchingModeEnum'
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
user_path_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
server_uri:
|
||||
type: string
|
||||
minLength: 1
|
||||
|
@ -23417,6 +23462,8 @@ components:
|
|||
can be overwritten by migrations. You can still modify the objects via
|
||||
the API, but expect changes to be overwritten in a later update.
|
||||
readOnly: true
|
||||
user_path_template:
|
||||
type: string
|
||||
provider_type:
|
||||
$ref: '#/components/schemas/ProviderTypeEnum'
|
||||
request_token_url:
|
||||
|
@ -23504,6 +23551,9 @@ components:
|
|||
- $ref: '#/components/schemas/UserMatchingModeEnum'
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
user_path_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
provider_type:
|
||||
$ref: '#/components/schemas/ProviderTypeEnum'
|
||||
request_token_url:
|
||||
|
@ -27500,6 +27550,9 @@ components:
|
|||
- $ref: '#/components/schemas/UserMatchingModeEnum'
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
user_path_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
server_uri:
|
||||
type: string
|
||||
minLength: 1
|
||||
|
@ -27734,6 +27787,9 @@ components:
|
|||
- $ref: '#/components/schemas/UserMatchingModeEnum'
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
user_path_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
provider_type:
|
||||
$ref: '#/components/schemas/ProviderTypeEnum'
|
||||
request_token_url:
|
||||
|
@ -27938,6 +27994,9 @@ components:
|
|||
- $ref: '#/components/schemas/UserMatchingModeEnum'
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
user_path_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
client_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
|
@ -28251,6 +28310,9 @@ components:
|
|||
- $ref: '#/components/schemas/UserMatchingModeEnum'
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
user_path_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
pre_authentication_flow:
|
||||
type: string
|
||||
format: uuid
|
||||
|
@ -28519,6 +28581,9 @@ components:
|
|||
attributes:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
PatchedUserWriteStageRequest:
|
||||
type: object
|
||||
description: UserWriteStage Serializer
|
||||
|
@ -28538,6 +28603,8 @@ components:
|
|||
format: uuid
|
||||
nullable: true
|
||||
description: Optionally add newly created users to this group.
|
||||
user_path_template:
|
||||
type: string
|
||||
PatchedWebAuthnDeviceRequest:
|
||||
type: object
|
||||
description: Serializer for WebAuthn authenticator devices
|
||||
|
@ -28647,6 +28714,8 @@ components:
|
|||
can be overwritten by migrations. You can still modify the objects via
|
||||
the API, but expect changes to be overwritten in a later update.
|
||||
readOnly: true
|
||||
user_path_template:
|
||||
type: string
|
||||
client_id:
|
||||
type: string
|
||||
description: Client identifier used to talk to Plex.
|
||||
|
@ -28743,6 +28812,9 @@ components:
|
|||
- $ref: '#/components/schemas/UserMatchingModeEnum'
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
user_path_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
client_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
|
@ -30048,6 +30120,8 @@ components:
|
|||
can be overwritten by migrations. You can still modify the objects via
|
||||
the API, but expect changes to be overwritten in a later update.
|
||||
readOnly: true
|
||||
user_path_template:
|
||||
type: string
|
||||
pre_authentication_flow:
|
||||
type: string
|
||||
format: uuid
|
||||
|
@ -30138,6 +30212,9 @@ components:
|
|||
- $ref: '#/components/schemas/UserMatchingModeEnum'
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
user_path_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
pre_authentication_flow:
|
||||
type: string
|
||||
format: uuid
|
||||
|
@ -30484,6 +30561,8 @@ components:
|
|||
can be overwritten by migrations. You can still modify the objects via
|
||||
the API, but expect changes to be overwritten in a later update.
|
||||
readOnly: true
|
||||
user_path_template:
|
||||
type: string
|
||||
required:
|
||||
- component
|
||||
- managed
|
||||
|
@ -30526,6 +30605,9 @@ components:
|
|||
- $ref: '#/components/schemas/UserMatchingModeEnum'
|
||||
description: How the source determines if an existing user should be authenticated
|
||||
or a new user enrolled.
|
||||
user_path_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- name
|
||||
- slug
|
||||
|
@ -31107,6 +31189,8 @@ components:
|
|||
uid:
|
||||
type: string
|
||||
readOnly: true
|
||||
path:
|
||||
type: string
|
||||
required:
|
||||
- avatar
|
||||
- groups
|
||||
|
@ -31369,6 +31453,16 @@ components:
|
|||
minLength: 1
|
||||
required:
|
||||
- password
|
||||
UserPath:
|
||||
type: object
|
||||
properties:
|
||||
paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
readOnly: true
|
||||
required:
|
||||
- paths
|
||||
UserRequest:
|
||||
type: object
|
||||
description: User Serializer
|
||||
|
@ -31402,6 +31496,9 @@ components:
|
|||
attributes:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- groups
|
||||
- name
|
||||
|
@ -31578,6 +31675,8 @@ components:
|
|||
format: uuid
|
||||
nullable: true
|
||||
description: Optionally add newly created users to this group.
|
||||
user_path_template:
|
||||
type: string
|
||||
required:
|
||||
- component
|
||||
- meta_model_name
|
||||
|
@ -31604,6 +31703,8 @@ components:
|
|||
format: uuid
|
||||
nullable: true
|
||||
description: Optionally add newly created users to this group.
|
||||
user_path_template:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
ValidationError:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { CoreApi, SessionUser } from "@goauthentik/api";
|
||||
import { CoreApi, ResponseError, SessionUser } from "@goauthentik/api";
|
||||
import { activateLocale } from "../interfaces/locale";
|
||||
import { DEFAULT_CONFIG } from "./Config";
|
||||
|
||||
|
@ -21,7 +21,7 @@ export function me(): Promise<SessionUser> {
|
|||
activateLocale(locale);
|
||||
}
|
||||
return user;
|
||||
}).catch((ex) => {
|
||||
}).catch((ex: ResponseError) => {
|
||||
const defaultUser: SessionUser = {
|
||||
user: {
|
||||
pk: -1,
|
||||
|
|
|
@ -277,6 +277,12 @@ html > form > input {
|
|||
.pf-c-select__menu-item:hover {
|
||||
--pf-c-select__menu-item--hover--BackgroundColor: var(--ak-dark-background-lighter);
|
||||
}
|
||||
.pf-c-select__menu-wrapper:focus-within,
|
||||
.pf-c-select__menu-wrapper.pf-m-focus,
|
||||
.pf-c-select__menu-item:focus,
|
||||
.pf-c-select__menu-item.pf-m-focus {
|
||||
--pf-c-select__menu-item--focus--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
}
|
||||
.pf-c-button.pf-m-plain:hover {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
|
@ -395,6 +401,14 @@ html > form > input {
|
|||
.pf-c-wizard__nav-link::before {
|
||||
--pf-c-wizard__nav-link--before--BackgroundColor: transparent;
|
||||
}
|
||||
/* tree view */
|
||||
.pf-c-tree-view__node:focus {
|
||||
--pf-c-tree-view__node--focus--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
}
|
||||
.pf-c-tree-view__content:hover,
|
||||
.pf-c-tree-view__content:focus-within {
|
||||
--pf-c-tree-view__node--hover--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
}
|
||||
}
|
||||
|
||||
.pf-c-data-list__item {
|
||||
|
|
206
web/src/elements/TreeView.ts
Normal file
206
web/src/elements/TreeView.ts
Normal file
|
@ -0,0 +1,206 @@
|
|||
import { t } from "@lingui/macro";
|
||||
|
||||
import { CSSResult, LitElement, TemplateResult, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
|
||||
import AKGlobal from "../authentik.css";
|
||||
import PFTreeView from "@patternfly/patternfly/components/TreeView/tree-view.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { EVENT_REFRESH } from "../constants";
|
||||
import { setURLParams } from "./router/RouteMatch";
|
||||
|
||||
export interface TreeViewItem {
|
||||
id: string;
|
||||
label: string;
|
||||
childItems: TreeViewItem[];
|
||||
parent?: TreeViewItem;
|
||||
level: number;
|
||||
}
|
||||
|
||||
@customElement("ak-treeview-node")
|
||||
export class TreeViewNode extends LitElement {
|
||||
@property({ attribute: false })
|
||||
item?: TreeViewItem;
|
||||
|
||||
@property({ type: Boolean })
|
||||
open = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
host?: TreeView;
|
||||
|
||||
@property()
|
||||
path = "";
|
||||
|
||||
@property()
|
||||
separator = "";
|
||||
|
||||
get openable(): boolean {
|
||||
return (this.item?.childItems || []).length > 0;
|
||||
}
|
||||
|
||||
get fullPath(): string {
|
||||
const pathItems = [];
|
||||
let item = this.item;
|
||||
while (item) {
|
||||
pathItems.push(item.id);
|
||||
item = item.parent;
|
||||
}
|
||||
return pathItems.reverse().join(this.separator);
|
||||
}
|
||||
|
||||
protected createRenderRoot(): Element {
|
||||
return this;
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
const pathSegments = this.path.split(this.separator);
|
||||
const level = this.item?.level || 0;
|
||||
// Ignore the last item as that shouldn't be expanded
|
||||
pathSegments.pop();
|
||||
if (pathSegments[level] == this.item?.id) {
|
||||
this.open = true;
|
||||
}
|
||||
if (this.path === this.fullPath && this.host !== undefined) {
|
||||
this.host.activeNode = this;
|
||||
}
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
const shouldRenderChildren = (this.item?.childItems || []).length > 0 && this.open;
|
||||
return html`
|
||||
<li
|
||||
class="pf-c-tree-view__list-item ${this.open ? "pf-m-expanded" : ""}"
|
||||
role="treeitem"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="pf-c-tree-view__content">
|
||||
<button
|
||||
class="pf-c-tree-view__node ${this.host?.activeNode === this
|
||||
? "pf-m-current"
|
||||
: ""}"
|
||||
@click=${() => {
|
||||
if (this.host) {
|
||||
this.host.activeNode = this;
|
||||
}
|
||||
setURLParams({ path: this.fullPath });
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(EVENT_REFRESH, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div class="pf-c-tree-view__node-container">
|
||||
${this.openable
|
||||
? html` <button
|
||||
class="pf-c-tree-view__node-toggle"
|
||||
@click=${(e: Event) => {
|
||||
if (this.openable) {
|
||||
this.open = !this.open;
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="pf-c-tree-view__node-toggle-icon">
|
||||
<i class="fas fa-angle-right" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>`
|
||||
: html``}
|
||||
<span class="pf-c-tree-view__node-icon">
|
||||
<i
|
||||
class="fas ${this.open ? "fa-folder-open" : "fa-folder"}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</span>
|
||||
<span class="pf-c-tree-view__node-text">${this.item?.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="pf-c-tree-view__list" role="group" ?hidden=${!shouldRenderChildren}>
|
||||
${this.item?.childItems.map((item) => {
|
||||
return html`<ak-treeview-node
|
||||
.item=${item}
|
||||
path=${this.path}
|
||||
separator=${this.separator}
|
||||
.host=${this.host}
|
||||
></ak-treeview-node>`;
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ak-treeview")
|
||||
export class TreeView extends LitElement {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFTreeView, AKGlobal];
|
||||
}
|
||||
|
||||
@property({ type: Array })
|
||||
items: string[] = [];
|
||||
|
||||
@property()
|
||||
path = "";
|
||||
|
||||
@state()
|
||||
activeNode?: TreeViewNode;
|
||||
|
||||
separator = "/";
|
||||
|
||||
createNode(path: string[], tree: TreeViewItem[], level: number): TreeViewItem {
|
||||
const id = path.shift();
|
||||
const idx = tree.findIndex((e: TreeViewItem) => {
|
||||
return e.id == id;
|
||||
});
|
||||
if (idx < 0) {
|
||||
const item: TreeViewItem = {
|
||||
id: id || "",
|
||||
label: id || "",
|
||||
childItems: [],
|
||||
level: level,
|
||||
};
|
||||
tree.push(item);
|
||||
if (path.length !== 0) {
|
||||
const child = this.createNode(path, tree[tree.length - 1].childItems, level + 1);
|
||||
child.parent = item;
|
||||
}
|
||||
return item;
|
||||
} else {
|
||||
return this.createNode(path, tree[idx].childItems, level + 1);
|
||||
}
|
||||
}
|
||||
|
||||
parse(data: string[]): TreeViewItem[] {
|
||||
const tree: TreeViewItem[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const path: string = data[i];
|
||||
const split: string[] = path.split(this.separator);
|
||||
this.createNode(split, tree, 0);
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
const result = this.parse(this.items);
|
||||
return html`<div class="pf-c-tree-view pf-m-guides">
|
||||
<ul class="pf-c-tree-view__list" role="tree">
|
||||
<!-- @ts-ignore -->
|
||||
<ak-treeview-node
|
||||
.item=${{
|
||||
id: "",
|
||||
label: t`Root`,
|
||||
childItems: result,
|
||||
level: -1,
|
||||
} as TreeViewItem}
|
||||
path=${this.path}
|
||||
?open=${true}
|
||||
separator=${this.separator}
|
||||
.host=${this}
|
||||
></ak-treeview-node>
|
||||
</ul>
|
||||
</div>`;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { CoreApi } from "@goauthentik/api";
|
||||
import { CoreApi, ResponseError } from "@goauthentik/api";
|
||||
|
||||
import { DEFAULT_CONFIG } from "../../api/Config";
|
||||
import { ERROR_CLASS, SECONDARY_CLASS, SUCCESS_CLASS } from "../../constants";
|
||||
|
@ -37,15 +37,15 @@ export class TokenCopyButton extends ActionButton {
|
|||
this.buttonClass = SUCCESS_CLASS;
|
||||
return token.key;
|
||||
})
|
||||
.catch((err: Error | Response | undefined) => {
|
||||
.catch((err: Error | ResponseError | undefined) => {
|
||||
this.buttonClass = ERROR_CLASS;
|
||||
if (err instanceof Error) {
|
||||
if (!(err instanceof ResponseError)) {
|
||||
setTimeout(() => {
|
||||
this.buttonClass = SECONDARY_CLASS;
|
||||
}, 1500);
|
||||
throw err;
|
||||
}
|
||||
return err?.json().then((errResp) => {
|
||||
return err.response.json().then((errResp) => {
|
||||
setTimeout(() => {
|
||||
this.buttonClass = SECONDARY_CLASS;
|
||||
}, 1500);
|
||||
|
@ -92,15 +92,15 @@ export class TokenCopyButton extends ActionButton {
|
|||
this.setDone(SUCCESS_CLASS);
|
||||
});
|
||||
})
|
||||
.catch((err: Response | Error) => {
|
||||
if (err instanceof Error) {
|
||||
.catch((err: ResponseError | Error) => {
|
||||
if (!(err instanceof ResponseError)) {
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return err?.json().then((errResp) => {
|
||||
return err.response.json().then((errResp) => {
|
||||
this.setDone(ERROR_CLASS);
|
||||
throw new Error(errResp["detail"]);
|
||||
});
|
||||
|
|
|
@ -14,7 +14,7 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
|
|||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { ValidationError } from "@goauthentik/api";
|
||||
import { ResponseError, ValidationError } from "@goauthentik/api";
|
||||
|
||||
import { EVENT_REFRESH } from "../../constants";
|
||||
import { showMessage } from "../../elements/messages/MessageContainer";
|
||||
|
@ -209,13 +209,13 @@ export class Form<T> extends LitElement {
|
|||
);
|
||||
return r;
|
||||
})
|
||||
.catch(async (ex: Response | Error) => {
|
||||
if (ex instanceof Error) {
|
||||
.catch(async (ex: Error | ResponseError) => {
|
||||
if (!(ex instanceof ResponseError)) {
|
||||
throw ex;
|
||||
}
|
||||
let msg = ex.statusText;
|
||||
if (ex.status > 399 && ex.status < 500) {
|
||||
const errorMessage: ValidationError = await ex.json();
|
||||
let msg = ex.response.statusText;
|
||||
if (ex.response.status > 399 && ex.response.status < 500) {
|
||||
const errorMessage: ValidationError = await ex.response.json();
|
||||
if (!errorMessage) return errorMessage;
|
||||
if (errorMessage instanceof Error) {
|
||||
throw errorMessage;
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
FlowsApi,
|
||||
LayoutEnum,
|
||||
RedirectChallenge,
|
||||
ResponseError,
|
||||
ShellChallenge,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
|
@ -193,7 +194,7 @@ export class FlowExecutor extends LitElement implements StageHost {
|
|||
}
|
||||
return true;
|
||||
})
|
||||
.catch((e: Error | Response) => {
|
||||
.catch((e: Error | ResponseError) => {
|
||||
this.errorMessage(e);
|
||||
return false;
|
||||
})
|
||||
|
@ -226,7 +227,7 @@ export class FlowExecutor extends LitElement implements StageHost {
|
|||
this.setBackground(this.challenge.flowInfo.background);
|
||||
}
|
||||
})
|
||||
.catch((e: Error | Response) => {
|
||||
.catch((e: Error | ResponseError) => {
|
||||
// Catch JSON or Update errors
|
||||
this.errorMessage(e);
|
||||
})
|
||||
|
@ -235,9 +236,11 @@ export class FlowExecutor extends LitElement implements StageHost {
|
|||
});
|
||||
}
|
||||
|
||||
async errorMessage(error: Error | Response): Promise<void> {
|
||||
async errorMessage(error: Error | ResponseError): Promise<void> {
|
||||
let body = "";
|
||||
if (error instanceof Error) {
|
||||
if (error instanceof ResponseError) {
|
||||
body = await error.response.text();
|
||||
} else if (error instanceof Error) {
|
||||
body = error.message;
|
||||
}
|
||||
this.challenge = {
|
||||
|
|
|
@ -15,6 +15,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
|||
import {
|
||||
PlexAuthenticationChallenge,
|
||||
PlexAuthenticationChallengeResponseRequest,
|
||||
ResponseError,
|
||||
} from "@goauthentik/api";
|
||||
import { SourcesApi } from "@goauthentik/api";
|
||||
|
||||
|
@ -48,8 +49,8 @@ export class PlexLoginInit extends BaseStage<
|
|||
.then((r) => {
|
||||
window.location.assign(r.to);
|
||||
})
|
||||
.catch((r: Response) => {
|
||||
r.json().then((body: { detail: string }) => {
|
||||
.catch((r: ResponseError) => {
|
||||
r.response.json().then((body: { detail: string }) => {
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: body.detail,
|
||||
|
|
|
@ -12,7 +12,7 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
|||
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { Flow, FlowsApi } from "@goauthentik/api";
|
||||
import { Flow, FlowsApi, ResponseError } from "@goauthentik/api";
|
||||
|
||||
import { AndNext, DEFAULT_CONFIG } from "../../api/Config";
|
||||
import "../../elements/PageHeader";
|
||||
|
@ -164,10 +164,13 @@ export class FlowViewPage extends LitElement {
|
|||
)}`;
|
||||
window.open(finalURL, "_blank");
|
||||
})
|
||||
.catch((exc: Response) => {
|
||||
.catch((exc: ResponseError) => {
|
||||
// This request can return a HTTP 400 when a flow
|
||||
// is not applicable.
|
||||
window.open(exc.url, "_blank");
|
||||
window.open(
|
||||
exc.response.url,
|
||||
"_blank",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -209,7 +209,7 @@ export class PolicyBindingForm extends ModelForm<PolicyBinding, string> {
|
|||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<Group[]> => {
|
||||
const args: CoreGroupsListRequest = {
|
||||
ordering: "username",
|
||||
ordering: "name",
|
||||
};
|
||||
if (query !== undefined) {
|
||||
args.search = query;
|
||||
|
|
|
@ -7,7 +7,9 @@ import { until } from "lit/directives/until.js";
|
|||
|
||||
import {
|
||||
CoreApi,
|
||||
CoreGroupsListRequest,
|
||||
CryptoApi,
|
||||
Group,
|
||||
LDAPSource,
|
||||
LDAPSourceRequest,
|
||||
PropertymappingsApi,
|
||||
|
@ -15,6 +17,7 @@ import {
|
|||
} from "@goauthentik/api";
|
||||
|
||||
import { DEFAULT_CONFIG } from "../../../api/Config";
|
||||
import "../../../elements/SearchSelect";
|
||||
import "../../../elements/forms/FormGroup";
|
||||
import "../../../elements/forms/HorizontalFormElement";
|
||||
import { ModelForm } from "../../../elements/forms/ModelForm";
|
||||
|
@ -301,31 +304,49 @@ export class LDAPSourceForm extends ModelForm<LDAPSource, string> {
|
|||
<span slot="header"> ${t`Additional settings`} </span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal label=${t`Group`} name="syncParentGroup">
|
||||
<select class="pf-c-form-control">
|
||||
<option
|
||||
value=""
|
||||
?selected=${this.instance?.syncParentGroup === undefined}
|
||||
>
|
||||
---------
|
||||
</option>
|
||||
${until(
|
||||
new CoreApi(DEFAULT_CONFIG).coreGroupsList({}).then((groups) => {
|
||||
return groups.results.map((group) => {
|
||||
return html`<option
|
||||
value=${ifDefined(group.pk)}
|
||||
?selected=${this.instance?.syncParentGroup === group.pk}
|
||||
>
|
||||
${group.name}
|
||||
</option>`;
|
||||
});
|
||||
}),
|
||||
html`<option>${t`Loading...`}</option>`,
|
||||
)}
|
||||
</select>
|
||||
<!-- @ts-ignore -->
|
||||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<Group[]> => {
|
||||
const args: CoreGroupsListRequest = {
|
||||
ordering: "name",
|
||||
};
|
||||
if (query !== undefined) {
|
||||
args.search = query;
|
||||
}
|
||||
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
|
||||
args,
|
||||
);
|
||||
return groups.results;
|
||||
}}
|
||||
.renderElement=${(group: Group): string => {
|
||||
return group.name;
|
||||
}}
|
||||
.value=${(group: Group | undefined): string | undefined => {
|
||||
return group ? group.pk : undefined;
|
||||
}}
|
||||
.selected=${(group: Group): boolean => {
|
||||
return group.pk === this.instance?.syncParentGroup;
|
||||
}}
|
||||
?blankable=${true}
|
||||
>
|
||||
</ak-search-select>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Parent group for all the groups imported from LDAP.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`User path`} name="userPathTemplate">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(
|
||||
this.instance?.userPathTemplate,
|
||||
"goauthentik.io/sources/%(slug)s",
|
||||
)}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Addition User DN`}
|
||||
name="additionalUserDn"
|
||||
|
|
|
@ -268,6 +268,19 @@ export class OAuthSourceForm extends ModelForm<OAuthSource, string> {
|
|||
</option>
|
||||
</select>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`User path`} name="userPathTemplate">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(
|
||||
this.instance?.userPathTemplate,
|
||||
"goauthentik.io/sources/%(slug)s",
|
||||
)}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-group .expanded=${true}>
|
||||
<span slot="header"> ${t`Protocol settings`} </span>
|
||||
|
|
|
@ -215,6 +215,19 @@ export class PlexSourceForm extends ModelForm<PlexSource, string> {
|
|||
</option>
|
||||
</select>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`User path`} name="userPathTemplate">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(
|
||||
this.instance?.userPathTemplate,
|
||||
"goauthentik.io/sources/%(slug)s",
|
||||
)}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-group .expanded=${true}>
|
||||
<span slot="header"> ${t`Protocol settings`} </span>
|
||||
|
|
|
@ -232,6 +232,19 @@ export class SAMLSourceForm extends ModelForm<SAMLSource, string> {
|
|||
</option>
|
||||
</select>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`User path`} name="userPathTemplate">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(
|
||||
this.instance?.userPathTemplate,
|
||||
"goauthentik.io/sources/%(slug)s",
|
||||
)}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Path template for users created. Use placeholders like \`%(slug)s\` to insert the source slug.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Delete temporary users after`}
|
||||
?required=${true}
|
||||
|
|
|
@ -3,11 +3,11 @@ import { t } from "@lingui/macro";
|
|||
import { TemplateResult, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { until } from "lit/directives/until.js";
|
||||
|
||||
import { CoreApi, StagesApi, UserWriteStage } from "@goauthentik/api";
|
||||
import { CoreApi, CoreGroupsListRequest, Group, StagesApi, UserWriteStage } from "@goauthentik/api";
|
||||
|
||||
import { DEFAULT_CONFIG } from "../../../api/Config";
|
||||
import "../../../elements/SearchSelect";
|
||||
import "../../../elements/forms/FormGroup";
|
||||
import "../../../elements/forms/HorizontalFormElement";
|
||||
import { ModelForm } from "../../../elements/forms/ModelForm";
|
||||
|
@ -74,29 +74,44 @@ export class UserWriteStageForm extends ModelForm<UserWriteStage, string> {
|
|||
${t`Mark newly created users as inactive.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="userPathTemplate">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(this.instance?.userPathTemplate, "")}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Path new users will be created under.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`Group`} name="createUsersGroup">
|
||||
<select class="pf-c-form-control">
|
||||
<option
|
||||
value=""
|
||||
?selected=${this.instance?.createUsersGroup === undefined}
|
||||
>
|
||||
---------
|
||||
</option>
|
||||
${until(
|
||||
new CoreApi(DEFAULT_CONFIG).coreGroupsList({}).then((groups) => {
|
||||
return groups.results.map((group) => {
|
||||
return html`<option
|
||||
value=${ifDefined(group.pk)}
|
||||
?selected=${this.instance?.createUsersGroup ===
|
||||
group.pk}
|
||||
>
|
||||
${group.name}
|
||||
</option>`;
|
||||
});
|
||||
}),
|
||||
html`<option>${t`Loading...`}</option>`,
|
||||
)}
|
||||
</select>
|
||||
<!-- @ts-ignore -->
|
||||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<Group[]> => {
|
||||
const args: CoreGroupsListRequest = {
|
||||
ordering: "name",
|
||||
};
|
||||
if (query !== undefined) {
|
||||
args.search = query;
|
||||
}
|
||||
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
|
||||
args,
|
||||
);
|
||||
return groups.results;
|
||||
}}
|
||||
.renderElement=${(group: Group): string => {
|
||||
return group.name;
|
||||
}}
|
||||
.value=${(group: Group | undefined): string | undefined => {
|
||||
return group ? group.pk : undefined;
|
||||
}}
|
||||
.selected=${(group: Group): boolean => {
|
||||
return group.pk === this.instance?.createUsersGroup;
|
||||
}}
|
||||
?blankable=${true}
|
||||
>
|
||||
</ak-search-select>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Newly created users are added to this group, if a group is selected.`}
|
||||
</p>
|
||||
|
|
|
@ -7,7 +7,7 @@ import { until } from "lit/directives/until.js";
|
|||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
|
||||
|
||||
import { CapabilitiesEnum, CoreApi, User } from "@goauthentik/api";
|
||||
import { CapabilitiesEnum, CoreApi, ResponseError, User } from "@goauthentik/api";
|
||||
|
||||
import { AKResponse } from "../../api/Client";
|
||||
import { DEFAULT_CONFIG, config, tenant } from "../../api/Config";
|
||||
|
@ -244,8 +244,8 @@ export class RelatedUserList extends Table<User> {
|
|||
description: rec.link,
|
||||
});
|
||||
})
|
||||
.catch((ex: Response) => {
|
||||
ex.json().then(() => {
|
||||
.catch((ex: ResponseError) => {
|
||||
ex.response.json().then(() => {
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: t`No recovery flow is configured.`,
|
||||
|
|
|
@ -69,6 +69,14 @@ export class UserForm extends ModelForm<User, number> {
|
|||
${t`User's primary identifier. 150 characters or fewer.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`Path`} ?required=${true} name="path">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(this.instance?.path, "users")}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`Name`} name="name">
|
||||
<input
|
||||
type="text"
|
||||
|
|
|
@ -4,10 +4,12 @@ import { CSSResult, TemplateResult, html } from "lit";
|
|||
import { customElement, property } from "lit/decorators.js";
|
||||
import { until } from "lit/directives/until.js";
|
||||
|
||||
import AKGlobal from "../../authentik.css";
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
||||
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
|
||||
|
||||
import { CapabilitiesEnum, CoreApi, User } from "@goauthentik/api";
|
||||
import { CapabilitiesEnum, CoreApi, ResponseError, User } from "@goauthentik/api";
|
||||
|
||||
import { AKResponse } from "../../api/Client";
|
||||
import { DEFAULT_CONFIG, config, tenant } from "../../api/Config";
|
||||
|
@ -15,12 +17,13 @@ import { me } from "../../api/Users";
|
|||
import { uiConfig } from "../../common/config";
|
||||
import { PFColor } from "../../elements/Label";
|
||||
import { PFSize } from "../../elements/Spinner";
|
||||
import "../../elements/TreeView";
|
||||
import "../../elements/buttons/ActionButton";
|
||||
import "../../elements/forms/DeleteBulkForm";
|
||||
import "../../elements/forms/ModalForm";
|
||||
import { MessageLevel } from "../../elements/messages/Message";
|
||||
import { showMessage } from "../../elements/messages/MessageContainer";
|
||||
import { getURLParam, updateURLParams } from "../../elements/router/RouteMatch";
|
||||
import { getURLParam } from "../../elements/router/RouteMatch";
|
||||
import { TableColumn } from "../../elements/table/Table";
|
||||
import { TablePage } from "../../elements/table/TablePage";
|
||||
import { first } from "../../utils";
|
||||
|
@ -51,11 +54,11 @@ export class UserListPage extends TablePage<User> {
|
|||
@property()
|
||||
order = "last_login";
|
||||
|
||||
@property({ type: Boolean })
|
||||
hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true);
|
||||
@property()
|
||||
path = getURLParam<string>("path", "/");
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return super.styles.concat(PFDescriptionList, PFAlert);
|
||||
return super.styles.concat(PFDescriptionList, PFCard, PFAlert, AKGlobal);
|
||||
}
|
||||
|
||||
async apiEndpoint(page: number): Promise<AKResponse<User>> {
|
||||
|
@ -64,11 +67,7 @@ export class UserListPage extends TablePage<User> {
|
|||
page: page,
|
||||
pageSize: (await uiConfig()).pagination.perPage,
|
||||
search: this.search || "",
|
||||
attributes: this.hideServiceAccounts
|
||||
? JSON.stringify({
|
||||
"goauthentik.io/user/service-account__isnull": true,
|
||||
})
|
||||
: undefined,
|
||||
pathStartswith: getURLParam("path", ""),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -251,8 +250,8 @@ export class UserListPage extends TablePage<User> {
|
|||
description: rec.link,
|
||||
});
|
||||
})
|
||||
.catch((ex: Response) => {
|
||||
ex.json().then(() => {
|
||||
.catch((ex: ResponseError) => {
|
||||
ex.response.json().then(() => {
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: t`No recovery flow is configured.`,
|
||||
|
@ -320,33 +319,25 @@ export class UserListPage extends TablePage<User> {
|
|||
`;
|
||||
}
|
||||
|
||||
renderToolbarAfter(): TemplateResult {
|
||||
return html`
|
||||
<div class="pf-c-toolbar__group pf-m-filter-group">
|
||||
<div class="pf-c-toolbar__item pf-m-search-filter">
|
||||
<div class="pf-c-input-group">
|
||||
<div class="pf-c-check">
|
||||
<input
|
||||
class="pf-c-check__input"
|
||||
type="checkbox"
|
||||
id="hide-service-accounts"
|
||||
name="hide-service-accounts"
|
||||
?checked=${this.hideServiceAccounts}
|
||||
@change=${() => {
|
||||
this.hideServiceAccounts = !this.hideServiceAccounts;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
hideServiceAccounts: this.hideServiceAccounts,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label class="pf-c-check__label" for="hide-service-accounts">
|
||||
${t`Hide service-accounts`}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
renderSidebarBefore(): TemplateResult {
|
||||
return html`<div class="pf-c-sidebar__panel pf-m-width-25">
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__title">${t`User folders`}</div>
|
||||
<div class="pf-c-card__body">
|
||||
${until(
|
||||
new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersPathsRetrieve({
|
||||
search: this.search,
|
||||
})
|
||||
.then((paths) => {
|
||||
return html`<ak-treeview
|
||||
.items=${paths.paths}
|
||||
path=${this.path}
|
||||
></ak-treeview>`;
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
FlowChallengeResponseRequest,
|
||||
FlowsApi,
|
||||
RedirectChallenge,
|
||||
ResponseError,
|
||||
ShellChallenge,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
|
@ -80,7 +81,7 @@ export class UserSettingsFlowExecutor extends LitElement implements StageHost {
|
|||
}
|
||||
return true;
|
||||
})
|
||||
.catch((e: Error | Response) => {
|
||||
.catch((e: Error | ResponseError) => {
|
||||
this.errorMessage(e);
|
||||
return false;
|
||||
})
|
||||
|
|
36
website/docs/releases/v2022.7.md
Normal file
36
website/docs/releases/v2022.7.md
Normal file
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
title: Release 2022.7
|
||||
slug: "2022.7"
|
||||
---
|
||||
|
||||
## Breaking changes
|
||||
|
||||
- Removal of verification certificates for Machine-to-Machine authentication in OAuth 2 Provider
|
||||
|
||||
Instead, create an OAuth Source with the certificate configured as JWKS Data, and enable the source in the provider.
|
||||
|
||||
## New features
|
||||
|
||||
- User paths
|
||||
|
||||
To better organize users, they can now be assigned a path. This allows for organization of users based on sources they enrolled with/got imported from, organizational structure or any other structure.
|
||||
|
||||
Sources now have a path template to specify which path users created by it should be assigned. Additionally, you can set the path in the user_write stage in any flow, and it can be dynamically overwritten within a flow's context.
|
||||
|
||||
## Upgrading
|
||||
|
||||
This release does not introduce any new requirements.
|
||||
|
||||
### docker-compose
|
||||
|
||||
Download the docker-compose file for 2022.7 from [here](https://goauthentik.io/version/2022.7/docker-compose.yml). Afterwards, simply run `docker-compose up -d`.
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Update your values to use the new images:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: ghcr.io/goauthentik/server
|
||||
tag: 2022.7.1
|
||||
```
|
|
@ -2,6 +2,14 @@
|
|||
title: User
|
||||
---
|
||||
|
||||
## Path
|
||||
|
||||
:::info
|
||||
Requires authentik 2022.7
|
||||
:::
|
||||
|
||||
Paths can be used to organize users into folders depending on which source created them or organizational structure. Paths may not start or end with a slash, but they can contain any other character as path segments. The paths are currently purely used for organization, it does not affect their permissions, group memberships, or anything else.
|
||||
|
||||
## Attributes
|
||||
|
||||
### `goauthentik.io/user/token-expires`:
|
||||
|
|
Reference in a new issue