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:
Jens L 2022-06-15 12:12:26 +02:00 committed by GitHub
parent c4b4c7134d
commit 1c62a3db6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 771 additions and 127 deletions

View File

@ -106,7 +106,7 @@ run:
web-build: web-install web-build: web-install
cd web && npm run build cd web && npm run build
web: web-lint-fix web-lint web-extract web: web-lint-fix web-lint
web-install: web-install:
cd web && npm ci cd web && npm ci

View File

@ -53,6 +53,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"policy_engine_mode", "policy_engine_mode",
"user_matching_mode", "user_matching_mode",
"managed", "managed",
"user_path_template",
] ]

View File

@ -24,7 +24,7 @@ from drf_spectacular.utils import (
) )
from guardian.shortcuts import get_anonymous_user, get_objects_for_user from guardian.shortcuts import get_anonymous_user, get_objects_for_user
from rest_framework.decorators import action 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.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ( from rest_framework.serializers import (
@ -50,6 +50,7 @@ from authentik.core.middleware import (
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_SA, USER_ATTRIBUTE_SA,
USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_PATH_SERVICE_ACCOUNT,
Group, Group,
Token, Token,
TokenIntents, TokenIntents,
@ -77,6 +78,15 @@ class UserSerializer(ModelSerializer):
uid = CharField(read_only=True) uid = CharField(read_only=True)
username = CharField(max_length=150) 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: class Meta:
model = User model = User
@ -93,6 +103,7 @@ class UserSerializer(ModelSerializer):
"avatar", "avatar",
"attributes", "attributes",
"uid", "uid",
"path",
] ]
extra_kwargs = { extra_kwargs = {
"name": {"allow_blank": True}, "name": {"allow_blank": True},
@ -208,6 +219,11 @@ class UsersFilter(FilterSet):
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser") is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
uuid = CharFilter(field_name="uuid") uuid = CharFilter(field_name="uuid")
path = CharFilter(
field_name="path",
)
path_startswith = CharFilter(field_name="path", lookup_expr="startswith")
groups_by_name = ModelMultipleChoiceFilter( groups_by_name = ModelMultipleChoiceFilter(
field_name="ak_groups__name", field_name="ak_groups__name",
to_field_name="name", to_field_name="name",
@ -314,6 +330,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
username=username, username=username,
name=username, name=username,
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False}, 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"): if create_group and self.request.user.has_perm("authentik_core.add_group"):
group = Group.objects.create( group = Group.objects.create(
@ -464,3 +481,32 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if self.request.user.has_perm("authentik_core.view_user"): if self.request.user.has_perm("authentik_core.view_user"):
return self._filter_queryset_for_list(queryset) return self._filter_queryset_for_list(queryset)
return super().filter_queryset(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)
)
}
)

View File

@ -12,9 +12,9 @@ import authentik.core.models
def create_default_user(apps: Apps, schema_editor: 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 django.contrib.auth.hashers import make_password
from authentik.core.models import User
User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
akadmin, _ = User.objects.using(db_alias).get_or_create( 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: if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
if password: if password:
akadmin.set_password(password, signal=False) akadmin.password = make_password(password)
else: else:
akadmin.set_unusable_password() akadmin.password = make_password(None)
akadmin.save() akadmin.save()

View File

@ -8,9 +8,9 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def create_default_user(apps: Apps, schema_editor: 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 django.contrib.auth.hashers import make_password
from authentik.core.models import User
User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
akadmin, _ = User.objects.using(db_alias).get_or_create( 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: if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
if password: if password:
akadmin.set_password(password, signal=False) akadmin.password = make_password(password)
else: else:
akadmin.set_unusable_password() akadmin.password = make_password(None)
akadmin.save() akadmin.save()

View 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"),
),
]

View File

@ -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_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" 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" GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("dist/assets/images/user_default.png") DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
@ -138,6 +141,7 @@ class User(GuardianUserMixin, AbstractUser):
uuid = models.UUIDField(default=uuid4, editable=False) uuid = models.UUIDField(default=uuid4, editable=False)
name = models.TextField(help_text=_("User's display name.")) name = models.TextField(help_text=_("User's display name."))
path = models.TextField(default="users")
sources = models.ManyToManyField("Source", through="UserSourceConnection") sources = models.ManyToManyField("Source", through="UserSourceConnection")
ak_groups = models.ManyToManyField("Group", related_name="users") ak_groups = models.ManyToManyField("Group", related_name="users")
@ -147,6 +151,11 @@ class User(GuardianUserMixin, AbstractUser):
objects = UserManager() 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]: def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
"""Get a dictionary containing the attributes from all groups the user belongs to, """Get a dictionary containing the attributes from all groups the user belongs to,
including the users attributes""" including the users attributes"""
@ -373,6 +382,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
name = models.TextField(help_text=_("Source's display Name.")) name = models.TextField(help_text=_("Source's display Name."))
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True) 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) enabled = models.BooleanField(default=True)
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
@ -408,6 +419,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
objects = InheritanceManager() 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 @property
def component(self) -> str: def component(self) -> str:
"""Return component used to edit this object""" """Return component used to edit this object"""

View File

@ -31,6 +31,7 @@ from authentik.policies.utils import delete_none_keys
from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
class Action(Enum): class Action(Enum):
@ -291,5 +292,6 @@ class SourceFlowManager:
connection, connection,
**{ **{
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
PLAN_CONTEXT_USER_PATH: self.source.get_user_path(),
}, },
) )

View File

@ -5,7 +5,7 @@ from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.flows.models import FlowDesignation 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.stages.email.models import EmailStage
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
@ -149,3 +149,65 @@ class TestUsersAPI(APITestCase):
}, },
) )
self.assertEqual(response.status_code, 400) 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."]}
)

View File

@ -20,6 +20,7 @@ from authentik import __version__, get_build_hash
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_CAN_OVERRIDE_IP, USER_ATTRIBUTE_CAN_OVERRIDE_IP,
USER_ATTRIBUTE_SA, USER_ATTRIBUTE_SA,
USER_PATH_SYSTEM_PREFIX,
Provider, Provider,
Token, Token,
TokenIntents, TokenIntents,
@ -39,6 +40,8 @@ OUR_VERSION = parse(__version__)
OUTPOST_HELLO_INTERVAL = 10 OUTPOST_HELLO_INTERVAL = 10
LOGGER = get_logger() LOGGER = get_logger()
USER_PATH_OUTPOSTS = USER_PATH_SYSTEM_PREFIX + "/outposts"
class ServiceConnectionInvalid(SentryIgnoredException): class ServiceConnectionInvalid(SentryIgnoredException):
"""Exception raised when a Service Connection has invalid parameters""" """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_SA] = True
user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
user.name = f"Outpost {self.name} Service-Account" user.name = f"Outpost {self.name} Service-Account"
user.path = USER_PATH_OUTPOSTS
user.save() user.save()
if should_create_user: if should_create_user:
self.build_user_permissions(user) self.build_user_permissions(user)

View File

@ -64,7 +64,9 @@ class BaseLDAPSynchronizer:
def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]: def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for User object based on property mappings.""" """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]: def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for Group object based on property mappings.""" """Build attributes for Group object based on property mappings."""

View File

@ -146,6 +146,7 @@ class ResponseProcessor:
USER_ATTRIBUTE_DELETE_ON_LOGOUT: True, USER_ATTRIBUTE_DELETE_ON_LOGOUT: True,
USER_ATTRIBUTE_EXPIRES: expiry, USER_ATTRIBUTE_EXPIRES: expiry,
}, },
path=self._source.get_user_path(),
) )
LOGGER.debug("Created temporary user for NameID Transient", username=name_id) LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
user.set_unusable_password() user.set_unusable_password()

View File

@ -12,7 +12,11 @@ class UserWriteStageSerializer(StageSerializer):
class Meta: class Meta:
model = UserWriteStage 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): class UserWriteStageViewSet(UsedByMixin, ModelViewSet):

View File

@ -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),
),
]

View File

@ -26,6 +26,11 @@ class UserWriteStage(Stage):
help_text=_("Optionally add newly created users to this group."), help_text=_("Optionally add newly created users to this group."),
) )
user_path_template = models.TextField(
default="",
blank=True,
)
@property @property
def serializer(self) -> BaseSerializer: def serializer(self) -> BaseSerializer:
from authentik.stages.user_write.api import UserWriteStageSerializer from authentik.stages.user_write.api import UserWriteStageSerializer

View File

@ -19,6 +19,7 @@ from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_write.signals import user_write from authentik.stages.user_write.signals import user_write
PLAN_CONTEXT_GROUPS = "groups" PLAN_CONTEXT_GROUPS = "groups"
PLAN_CONTEXT_USER_PATH = "user_path"
class UserWriteStageView(StageView): class UserWriteStageView(StageView):
@ -49,9 +50,15 @@ class UserWriteStageView(StageView):
def ensure_user(self) -> tuple[User, bool]: def ensure_user(self) -> tuple[User, bool]:
"""Ensure a user exists""" """Ensure a user exists"""
user_created = False 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: if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User( 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.executor.plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = BACKEND_INBUILT
self.logger.debug( self.logger.debug(

View File

@ -3067,6 +3067,14 @@ paths:
description: Number of results to return per page. description: Number of results to return per page.
schema: schema:
type: integer type: integer
- in: query
name: path
schema:
type: string
- in: query
name: path_startswith
schema:
type: string
- name: search - name: search
required: false required: false
in: query in: query
@ -3390,6 +3398,30 @@ paths:
$ref: '#/components/schemas/ValidationError' $ref: '#/components/schemas/ValidationError'
'403': '403':
$ref: '#/components/schemas/GenericError' $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/: /core/users/service_account/:
post: post:
operationId: core_users_service_account_create operationId: core_users_service_account_create
@ -13133,6 +13165,10 @@ paths:
- username_link - username_link
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
- in: query
name: user_path_template
schema:
type: string
tags: tags:
- sources - sources
security: security:
@ -18826,6 +18862,10 @@ paths:
schema: schema:
type: string type: string
format: uuid format: uuid
- in: query
name: user_path_template
schema:
type: string
tags: tags:
- stages - stages
security: security:
@ -22705,6 +22745,8 @@ components:
can be overwritten by migrations. You can still modify the objects via can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update. the API, but expect changes to be overwritten in a later update.
readOnly: true readOnly: true
user_path_template:
type: string
server_uri: server_uri:
type: string type: string
format: uri format: uri
@ -22808,6 +22850,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum' - $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
user_path_template:
type: string
minLength: 1
server_uri: server_uri:
type: string type: string
minLength: 1 minLength: 1
@ -23417,6 +23462,8 @@ components:
can be overwritten by migrations. You can still modify the objects via can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update. the API, but expect changes to be overwritten in a later update.
readOnly: true readOnly: true
user_path_template:
type: string
provider_type: provider_type:
$ref: '#/components/schemas/ProviderTypeEnum' $ref: '#/components/schemas/ProviderTypeEnum'
request_token_url: request_token_url:
@ -23504,6 +23551,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum' - $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
user_path_template:
type: string
minLength: 1
provider_type: provider_type:
$ref: '#/components/schemas/ProviderTypeEnum' $ref: '#/components/schemas/ProviderTypeEnum'
request_token_url: request_token_url:
@ -27500,6 +27550,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum' - $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
user_path_template:
type: string
minLength: 1
server_uri: server_uri:
type: string type: string
minLength: 1 minLength: 1
@ -27734,6 +27787,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum' - $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
user_path_template:
type: string
minLength: 1
provider_type: provider_type:
$ref: '#/components/schemas/ProviderTypeEnum' $ref: '#/components/schemas/ProviderTypeEnum'
request_token_url: request_token_url:
@ -27938,6 +27994,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum' - $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
user_path_template:
type: string
minLength: 1
client_id: client_id:
type: string type: string
minLength: 1 minLength: 1
@ -28251,6 +28310,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum' - $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
user_path_template:
type: string
minLength: 1
pre_authentication_flow: pre_authentication_flow:
type: string type: string
format: uuid format: uuid
@ -28519,6 +28581,9 @@ components:
attributes: attributes:
type: object type: object
additionalProperties: {} additionalProperties: {}
path:
type: string
minLength: 1
PatchedUserWriteStageRequest: PatchedUserWriteStageRequest:
type: object type: object
description: UserWriteStage Serializer description: UserWriteStage Serializer
@ -28538,6 +28603,8 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Optionally add newly created users to this group. description: Optionally add newly created users to this group.
user_path_template:
type: string
PatchedWebAuthnDeviceRequest: PatchedWebAuthnDeviceRequest:
type: object type: object
description: Serializer for WebAuthn authenticator devices description: Serializer for WebAuthn authenticator devices
@ -28647,6 +28714,8 @@ components:
can be overwritten by migrations. You can still modify the objects via can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update. the API, but expect changes to be overwritten in a later update.
readOnly: true readOnly: true
user_path_template:
type: string
client_id: client_id:
type: string type: string
description: Client identifier used to talk to Plex. description: Client identifier used to talk to Plex.
@ -28743,6 +28812,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum' - $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
user_path_template:
type: string
minLength: 1
client_id: client_id:
type: string type: string
minLength: 1 minLength: 1
@ -30048,6 +30120,8 @@ components:
can be overwritten by migrations. You can still modify the objects via can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update. the API, but expect changes to be overwritten in a later update.
readOnly: true readOnly: true
user_path_template:
type: string
pre_authentication_flow: pre_authentication_flow:
type: string type: string
format: uuid format: uuid
@ -30138,6 +30212,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum' - $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
user_path_template:
type: string
minLength: 1
pre_authentication_flow: pre_authentication_flow:
type: string type: string
format: uuid format: uuid
@ -30484,6 +30561,8 @@ components:
can be overwritten by migrations. You can still modify the objects via can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update. the API, but expect changes to be overwritten in a later update.
readOnly: true readOnly: true
user_path_template:
type: string
required: required:
- component - component
- managed - managed
@ -30526,6 +30605,9 @@ components:
- $ref: '#/components/schemas/UserMatchingModeEnum' - $ref: '#/components/schemas/UserMatchingModeEnum'
description: How the source determines if an existing user should be authenticated description: How the source determines if an existing user should be authenticated
or a new user enrolled. or a new user enrolled.
user_path_template:
type: string
minLength: 1
required: required:
- name - name
- slug - slug
@ -31107,6 +31189,8 @@ components:
uid: uid:
type: string type: string
readOnly: true readOnly: true
path:
type: string
required: required:
- avatar - avatar
- groups - groups
@ -31369,6 +31453,16 @@ components:
minLength: 1 minLength: 1
required: required:
- password - password
UserPath:
type: object
properties:
paths:
type: array
items:
type: string
readOnly: true
required:
- paths
UserRequest: UserRequest:
type: object type: object
description: User Serializer description: User Serializer
@ -31402,6 +31496,9 @@ components:
attributes: attributes:
type: object type: object
additionalProperties: {} additionalProperties: {}
path:
type: string
minLength: 1
required: required:
- groups - groups
- name - name
@ -31578,6 +31675,8 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Optionally add newly created users to this group. description: Optionally add newly created users to this group.
user_path_template:
type: string
required: required:
- component - component
- meta_model_name - meta_model_name
@ -31604,6 +31703,8 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Optionally add newly created users to this group. description: Optionally add newly created users to this group.
user_path_template:
type: string
required: required:
- name - name
ValidationError: ValidationError:

View File

@ -1,4 +1,4 @@
import { CoreApi, SessionUser } from "@goauthentik/api"; import { CoreApi, ResponseError, SessionUser } from "@goauthentik/api";
import { activateLocale } from "../interfaces/locale"; import { activateLocale } from "../interfaces/locale";
import { DEFAULT_CONFIG } from "./Config"; import { DEFAULT_CONFIG } from "./Config";
@ -21,7 +21,7 @@ export function me(): Promise<SessionUser> {
activateLocale(locale); activateLocale(locale);
} }
return user; return user;
}).catch((ex) => { }).catch((ex: ResponseError) => {
const defaultUser: SessionUser = { const defaultUser: SessionUser = {
user: { user: {
pk: -1, pk: -1,

View File

@ -277,6 +277,12 @@ html > form > input {
.pf-c-select__menu-item:hover { .pf-c-select__menu-item:hover {
--pf-c-select__menu-item--hover--BackgroundColor: var(--ak-dark-background-lighter); --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 { .pf-c-button.pf-m-plain:hover {
color: var(--ak-dark-foreground); color: var(--ak-dark-foreground);
} }
@ -395,6 +401,14 @@ html > form > input {
.pf-c-wizard__nav-link::before { .pf-c-wizard__nav-link::before {
--pf-c-wizard__nav-link--before--BackgroundColor: transparent; --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 { .pf-c-data-list__item {

View 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>`;
}
}

View File

@ -1,7 +1,7 @@
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js"; 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 { DEFAULT_CONFIG } from "../../api/Config";
import { ERROR_CLASS, SECONDARY_CLASS, SUCCESS_CLASS } from "../../constants"; import { ERROR_CLASS, SECONDARY_CLASS, SUCCESS_CLASS } from "../../constants";
@ -37,15 +37,15 @@ export class TokenCopyButton extends ActionButton {
this.buttonClass = SUCCESS_CLASS; this.buttonClass = SUCCESS_CLASS;
return token.key; return token.key;
}) })
.catch((err: Error | Response | undefined) => { .catch((err: Error | ResponseError | undefined) => {
this.buttonClass = ERROR_CLASS; this.buttonClass = ERROR_CLASS;
if (err instanceof Error) { if (!(err instanceof ResponseError)) {
setTimeout(() => { setTimeout(() => {
this.buttonClass = SECONDARY_CLASS; this.buttonClass = SECONDARY_CLASS;
}, 1500); }, 1500);
throw err; throw err;
} }
return err?.json().then((errResp) => { return err.response.json().then((errResp) => {
setTimeout(() => { setTimeout(() => {
this.buttonClass = SECONDARY_CLASS; this.buttonClass = SECONDARY_CLASS;
}, 1500); }, 1500);
@ -92,15 +92,15 @@ export class TokenCopyButton extends ActionButton {
this.setDone(SUCCESS_CLASS); this.setDone(SUCCESS_CLASS);
}); });
}) })
.catch((err: Response | Error) => { .catch((err: ResponseError | Error) => {
if (err instanceof Error) { if (!(err instanceof ResponseError)) {
showMessage({ showMessage({
level: MessageLevel.error, level: MessageLevel.error,
message: err.message, message: err.message,
}); });
return; return;
} }
return err?.json().then((errResp) => { return err.response.json().then((errResp) => {
this.setDone(ERROR_CLASS); this.setDone(ERROR_CLASS);
throw new Error(errResp["detail"]); throw new Error(errResp["detail"]);
}); });

View File

@ -14,7 +14,7 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css"; import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
import PFBase from "@patternfly/patternfly/patternfly-base.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 { EVENT_REFRESH } from "../../constants";
import { showMessage } from "../../elements/messages/MessageContainer"; import { showMessage } from "../../elements/messages/MessageContainer";
@ -209,13 +209,13 @@ export class Form<T> extends LitElement {
); );
return r; return r;
}) })
.catch(async (ex: Response | Error) => { .catch(async (ex: Error | ResponseError) => {
if (ex instanceof Error) { if (!(ex instanceof ResponseError)) {
throw ex; throw ex;
} }
let msg = ex.statusText; let msg = ex.response.statusText;
if (ex.status > 399 && ex.status < 500) { if (ex.response.status > 399 && ex.response.status < 500) {
const errorMessage: ValidationError = await ex.json(); const errorMessage: ValidationError = await ex.response.json();
if (!errorMessage) return errorMessage; if (!errorMessage) return errorMessage;
if (errorMessage instanceof Error) { if (errorMessage instanceof Error) {
throw errorMessage; throw errorMessage;

View File

@ -22,6 +22,7 @@ import {
FlowsApi, FlowsApi,
LayoutEnum, LayoutEnum,
RedirectChallenge, RedirectChallenge,
ResponseError,
ShellChallenge, ShellChallenge,
} from "@goauthentik/api"; } from "@goauthentik/api";
@ -193,7 +194,7 @@ export class FlowExecutor extends LitElement implements StageHost {
} }
return true; return true;
}) })
.catch((e: Error | Response) => { .catch((e: Error | ResponseError) => {
this.errorMessage(e); this.errorMessage(e);
return false; return false;
}) })
@ -226,7 +227,7 @@ export class FlowExecutor extends LitElement implements StageHost {
this.setBackground(this.challenge.flowInfo.background); this.setBackground(this.challenge.flowInfo.background);
} }
}) })
.catch((e: Error | Response) => { .catch((e: Error | ResponseError) => {
// Catch JSON or Update errors // Catch JSON or Update errors
this.errorMessage(e); 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 = ""; let body = "";
if (error instanceof Error) { if (error instanceof ResponseError) {
body = await error.response.text();
} else if (error instanceof Error) {
body = error.message; body = error.message;
} }
this.challenge = { this.challenge = {

View File

@ -15,6 +15,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { import {
PlexAuthenticationChallenge, PlexAuthenticationChallenge,
PlexAuthenticationChallengeResponseRequest, PlexAuthenticationChallengeResponseRequest,
ResponseError,
} from "@goauthentik/api"; } from "@goauthentik/api";
import { SourcesApi } from "@goauthentik/api"; import { SourcesApi } from "@goauthentik/api";
@ -48,8 +49,8 @@ export class PlexLoginInit extends BaseStage<
.then((r) => { .then((r) => {
window.location.assign(r.to); window.location.assign(r.to);
}) })
.catch((r: Response) => { .catch((r: ResponseError) => {
r.json().then((body: { detail: string }) => { r.response.json().then((body: { detail: string }) => {
showMessage({ showMessage({
level: MessageLevel.error, level: MessageLevel.error,
message: body.detail, message: body.detail,

View File

@ -12,7 +12,7 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.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 { AndNext, DEFAULT_CONFIG } from "../../api/Config";
import "../../elements/PageHeader"; import "../../elements/PageHeader";
@ -164,10 +164,13 @@ export class FlowViewPage extends LitElement {
)}`; )}`;
window.open(finalURL, "_blank"); window.open(finalURL, "_blank");
}) })
.catch((exc: Response) => { .catch((exc: ResponseError) => {
// This request can return a HTTP 400 when a flow // This request can return a HTTP 400 when a flow
// is not applicable. // is not applicable.
window.open(exc.url, "_blank"); window.open(
exc.response.url,
"_blank",
);
}); });
}} }}
> >

View File

@ -209,7 +209,7 @@ export class PolicyBindingForm extends ModelForm<PolicyBinding, string> {
<ak-search-select <ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => { .fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = { const args: CoreGroupsListRequest = {
ordering: "username", ordering: "name",
}; };
if (query !== undefined) { if (query !== undefined) {
args.search = query; args.search = query;

View File

@ -7,7 +7,9 @@ import { until } from "lit/directives/until.js";
import { import {
CoreApi, CoreApi,
CoreGroupsListRequest,
CryptoApi, CryptoApi,
Group,
LDAPSource, LDAPSource,
LDAPSourceRequest, LDAPSourceRequest,
PropertymappingsApi, PropertymappingsApi,
@ -15,6 +17,7 @@ import {
} from "@goauthentik/api"; } from "@goauthentik/api";
import { DEFAULT_CONFIG } from "../../../api/Config"; import { DEFAULT_CONFIG } from "../../../api/Config";
import "../../../elements/SearchSelect";
import "../../../elements/forms/FormGroup"; import "../../../elements/forms/FormGroup";
import "../../../elements/forms/HorizontalFormElement"; import "../../../elements/forms/HorizontalFormElement";
import { ModelForm } from "../../../elements/forms/ModelForm"; import { ModelForm } from "../../../elements/forms/ModelForm";
@ -301,31 +304,49 @@ export class LDAPSourceForm extends ModelForm<LDAPSource, string> {
<span slot="header"> ${t`Additional settings`} </span> <span slot="header"> ${t`Additional settings`} </span>
<div slot="body" class="pf-c-form"> <div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${t`Group`} name="syncParentGroup"> <ak-form-element-horizontal label=${t`Group`} name="syncParentGroup">
<select class="pf-c-form-control"> <!-- @ts-ignore -->
<option <ak-search-select
value="" .fetchObjects=${async (query?: string): Promise<Group[]> => {
?selected=${this.instance?.syncParentGroup === undefined} const args: CoreGroupsListRequest = {
> ordering: "name",
--------- };
</option> if (query !== undefined) {
${until( args.search = query;
new CoreApi(DEFAULT_CONFIG).coreGroupsList({}).then((groups) => { }
return groups.results.map((group) => { const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
return html`<option args,
value=${ifDefined(group.pk)} );
?selected=${this.instance?.syncParentGroup === group.pk} return groups.results;
> }}
${group.name} .renderElement=${(group: Group): string => {
</option>`; return group.name;
}); }}
}), .value=${(group: Group | undefined): string | undefined => {
html`<option>${t`Loading...`}</option>`, return group ? group.pk : undefined;
)} }}
</select> .selected=${(group: Group): boolean => {
return group.pk === this.instance?.syncParentGroup;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${t`Parent group for all the groups imported from LDAP.`} ${t`Parent group for all the groups imported from LDAP.`}
</p> </p>
</ak-form-element-horizontal> </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 <ak-form-element-horizontal
label=${t`Addition User DN`} label=${t`Addition User DN`}
name="additionalUserDn" name="additionalUserDn"

View File

@ -268,6 +268,19 @@ export class OAuthSourceForm extends ModelForm<OAuthSource, string> {
</option> </option>
</select> </select>
</ak-form-element-horizontal> </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}> <ak-form-group .expanded=${true}>
<span slot="header"> ${t`Protocol settings`} </span> <span slot="header"> ${t`Protocol settings`} </span>

View File

@ -215,6 +215,19 @@ export class PlexSourceForm extends ModelForm<PlexSource, string> {
</option> </option>
</select> </select>
</ak-form-element-horizontal> </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}> <ak-form-group .expanded=${true}>
<span slot="header"> ${t`Protocol settings`} </span> <span slot="header"> ${t`Protocol settings`} </span>

View File

@ -232,6 +232,19 @@ export class SAMLSourceForm extends ModelForm<SAMLSource, string> {
</option> </option>
</select> </select>
</ak-form-element-horizontal> </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 <ak-form-element-horizontal
label=${t`Delete temporary users after`} label=${t`Delete temporary users after`}
?required=${true} ?required=${true}

View File

@ -3,11 +3,11 @@ import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.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 { DEFAULT_CONFIG } from "../../../api/Config";
import "../../../elements/SearchSelect";
import "../../../elements/forms/FormGroup"; import "../../../elements/forms/FormGroup";
import "../../../elements/forms/HorizontalFormElement"; import "../../../elements/forms/HorizontalFormElement";
import { ModelForm } from "../../../elements/forms/ModelForm"; import { ModelForm } from "../../../elements/forms/ModelForm";
@ -74,29 +74,44 @@ export class UserWriteStageForm extends ModelForm<UserWriteStage, string> {
${t`Mark newly created users as inactive.`} ${t`Mark newly created users as inactive.`}
</p> </p>
</ak-form-element-horizontal> </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"> <ak-form-element-horizontal label=${t`Group`} name="createUsersGroup">
<select class="pf-c-form-control"> <!-- @ts-ignore -->
<option <ak-search-select
value="" .fetchObjects=${async (query?: string): Promise<Group[]> => {
?selected=${this.instance?.createUsersGroup === undefined} const args: CoreGroupsListRequest = {
> ordering: "name",
--------- };
</option> if (query !== undefined) {
${until( args.search = query;
new CoreApi(DEFAULT_CONFIG).coreGroupsList({}).then((groups) => { }
return groups.results.map((group) => { const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
return html`<option args,
value=${ifDefined(group.pk)} );
?selected=${this.instance?.createUsersGroup === return groups.results;
group.pk} }}
> .renderElement=${(group: Group): string => {
${group.name} return group.name;
</option>`; }}
}); .value=${(group: Group | undefined): string | undefined => {
}), return group ? group.pk : undefined;
html`<option>${t`Loading...`}</option>`, }}
)} .selected=${(group: Group): boolean => {
</select> return group.pk === this.instance?.createUsersGroup;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${t`Newly created users are added to this group, if a group is selected.`} ${t`Newly created users are added to this group, if a group is selected.`}
</p> </p>

View File

@ -7,7 +7,7 @@ import { until } from "lit/directives/until.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.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 { AKResponse } from "../../api/Client";
import { DEFAULT_CONFIG, config, tenant } from "../../api/Config"; import { DEFAULT_CONFIG, config, tenant } from "../../api/Config";
@ -244,8 +244,8 @@ export class RelatedUserList extends Table<User> {
description: rec.link, description: rec.link,
}); });
}) })
.catch((ex: Response) => { .catch((ex: ResponseError) => {
ex.json().then(() => { ex.response.json().then(() => {
showMessage({ showMessage({
level: MessageLevel.error, level: MessageLevel.error,
message: t`No recovery flow is configured.`, message: t`No recovery flow is configured.`,

View File

@ -69,6 +69,14 @@ export class UserForm extends ModelForm<User, number> {
${t`User's primary identifier. 150 characters or fewer.`} ${t`User's primary identifier. 150 characters or fewer.`}
</p> </p>
</ak-form-element-horizontal> </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"> <ak-form-element-horizontal label=${t`Name`} name="name">
<input <input
type="text" type="text"

View File

@ -4,10 +4,12 @@ import { CSSResult, TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { until } from "lit/directives/until.js"; import { until } from "lit/directives/until.js";
import AKGlobal from "../../authentik.css";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.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 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 { AKResponse } from "../../api/Client";
import { DEFAULT_CONFIG, config, tenant } from "../../api/Config"; import { DEFAULT_CONFIG, config, tenant } from "../../api/Config";
@ -15,12 +17,13 @@ import { me } from "../../api/Users";
import { uiConfig } from "../../common/config"; import { uiConfig } from "../../common/config";
import { PFColor } from "../../elements/Label"; import { PFColor } from "../../elements/Label";
import { PFSize } from "../../elements/Spinner"; import { PFSize } from "../../elements/Spinner";
import "../../elements/TreeView";
import "../../elements/buttons/ActionButton"; import "../../elements/buttons/ActionButton";
import "../../elements/forms/DeleteBulkForm"; import "../../elements/forms/DeleteBulkForm";
import "../../elements/forms/ModalForm"; import "../../elements/forms/ModalForm";
import { MessageLevel } from "../../elements/messages/Message"; import { MessageLevel } from "../../elements/messages/Message";
import { showMessage } from "../../elements/messages/MessageContainer"; 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 { TableColumn } from "../../elements/table/Table";
import { TablePage } from "../../elements/table/TablePage"; import { TablePage } from "../../elements/table/TablePage";
import { first } from "../../utils"; import { first } from "../../utils";
@ -51,11 +54,11 @@ export class UserListPage extends TablePage<User> {
@property() @property()
order = "last_login"; order = "last_login";
@property({ type: Boolean }) @property()
hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true); path = getURLParam<string>("path", "/");
static get styles(): CSSResult[] { 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>> { async apiEndpoint(page: number): Promise<AKResponse<User>> {
@ -64,11 +67,7 @@ export class UserListPage extends TablePage<User> {
page: page, page: page,
pageSize: (await uiConfig()).pagination.perPage, pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "", search: this.search || "",
attributes: this.hideServiceAccounts pathStartswith: getURLParam("path", ""),
? JSON.stringify({
"goauthentik.io/user/service-account__isnull": true,
})
: undefined,
}); });
} }
@ -251,8 +250,8 @@ export class UserListPage extends TablePage<User> {
description: rec.link, description: rec.link,
}); });
}) })
.catch((ex: Response) => { .catch((ex: ResponseError) => {
ex.json().then(() => { ex.response.json().then(() => {
showMessage({ showMessage({
level: MessageLevel.error, level: MessageLevel.error,
message: t`No recovery flow is configured.`, message: t`No recovery flow is configured.`,
@ -320,33 +319,25 @@ export class UserListPage extends TablePage<User> {
`; `;
} }
renderToolbarAfter(): TemplateResult { renderSidebarBefore(): TemplateResult {
return html`&nbsp; return html`<div class="pf-c-sidebar__panel pf-m-width-25">
<div class="pf-c-toolbar__group pf-m-filter-group"> <div class="pf-c-card">
<div class="pf-c-toolbar__item pf-m-search-filter"> <div class="pf-c-card__title">${t`User folders`}</div>
<div class="pf-c-input-group"> <div class="pf-c-card__body">
<div class="pf-c-check"> ${until(
<input new CoreApi(DEFAULT_CONFIG)
class="pf-c-check__input" .coreUsersPathsRetrieve({
type="checkbox" search: this.search,
id="hide-service-accounts" })
name="hide-service-accounts" .then((paths) => {
?checked=${this.hideServiceAccounts} return html`<ak-treeview
@change=${() => { .items=${paths.paths}
this.hideServiceAccounts = !this.hideServiceAccounts; path=${this.path}
this.page = 1; ></ak-treeview>`;
this.fetch(); }),
updateURLParams({ )}
hideServiceAccounts: this.hideServiceAccounts,
});
}}
/>
<label class="pf-c-check__label" for="hide-service-accounts">
${t`Hide service-accounts`}
</label>
</div>
</div>
</div> </div>
</div>`; </div>
</div>`;
} }
} }

View File

@ -18,6 +18,7 @@ import {
FlowChallengeResponseRequest, FlowChallengeResponseRequest,
FlowsApi, FlowsApi,
RedirectChallenge, RedirectChallenge,
ResponseError,
ShellChallenge, ShellChallenge,
} from "@goauthentik/api"; } from "@goauthentik/api";
@ -80,7 +81,7 @@ export class UserSettingsFlowExecutor extends LitElement implements StageHost {
} }
return true; return true;
}) })
.catch((e: Error | Response) => { .catch((e: Error | ResponseError) => {
this.errorMessage(e); this.errorMessage(e);
return false; return false;
}) })

View 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
```

View File

@ -2,6 +2,14 @@
title: User 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 ## Attributes
### `goauthentik.io/user/token-expires`: ### `goauthentik.io/user/token-expires`: