Compare commits

...

9 Commits

Author SHA1 Message Date
f97f4a902c web: fixing styling correspondence 2024-07-08 09:16:41 -07:00
de8da15293 web: clean up group displays and type correspondence. 2024-07-08 09:13:07 -07:00
cd7d96cc58 Fix user errors; builds without failure now. 2024-07-08 08:53:49 -07:00
dcaa41716b web: a little css magic to hide the sidebar button in user mode. 2024-07-08 08:50:03 -07:00
910b430d25 Fixing the project icon. 2024-07-08 08:40:44 -07:00
832c00c155 Merge branch 'main' into risson/user-directory
* main: (350 commits)
  web: bump @swc/core from 1.6.6 to 1.6.7 in /web/sfe (#10395)
  web: bump @sentry/browser from 8.14.0 to 8.15.0 in /web in the sentry group across 1 directory (#10388)
  website/integrations: aws: cleanup (#10355)
  web: bump API Client version (#10389)
  web/flows: Simplified flow executor (#10296)
  website/docs: sources: ldap: remove extra example (#10387)
  website/docs: add new content from old PR #9524 (#10158)
  stages/authenticator_validate: fix friendly_name being required (#10382)
  core: bump go api client (#10383)
  web: bump API Client version (#10381)
  outposts: make refresh interval configurable (#10138)
  core, web: update translations (#10378)
  web: bump @sentry/browser from 8.13.0 to 8.14.0 in /web in the sentry group (#10379)
  core: bump goauthentik.io/api/v3 from 3.2024060.3 to 3.2024060.4 (#10380)
  sources/oauth: fix link not being saved (#10374)
  website/docs: update postgres on docker: fix backtick (#10372)
  website/integrations: apache guacamole: cleanup doc page (#10354)
  web: bump API Client version (#10371)
  Revert "core: applications api: add option to only list apps with launch url (#10336)" (#10370)
  web: bump @wdio/cli from 8.39.0 to 8.39.1 in /web (#10362)
  ...
2024-07-05 16:00:57 -07:00
399fa0120c Not sure what's wrong; it builds, but the paths are wrong and I don't have the bandwidth to figure it out right now. By that, I mean that the URLs aren't resolving as I would expect, so I'm getting 404s on the front end. 2024-05-24 13:14:36 -07:00
3de3c98ed8 Merge branch 'main' into risson/user-directory
* main: (1016 commits)
  website/docs: improve read replica docs (#9828)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in ru (#9859)
  web: bump API Client version (#9857)
  lib/sync/outgoing, sources/ldap: fix sync status endpoint (#9855)
  lifecycle/migrate: only acquire lock once (#9856)
  core: add option to select group for property mapping testing (#9834)
  policies: fix ak_call_policy failing when used in testing (#9853)
  website/integrations: gitlab: make placeholder clearer (#9838)
  core: bump requests from 2.31.0 to 2.32.2 (#9852)
  core: bump codespell from 2.2.6 to 2.3.0 (#9842)
  core: bump docker from 7.0.0 to 7.1.0 (#9843)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#9839)
  translate: Updates for file web/xliff/en.xlf in zh_CN (#9840)
  core: bump watchdog from 4.0.0 to 4.0.1 (#9845)
  website: bump @types/react from 18.3.2 to 18.3.3 in /website (#9841)
  core: bump sentry-sdk from 2.2.1 to 2.3.1 (#9844)
  core: bump goauthentik.io/api/v3 from 3.2024042.4 to 3.2024042.7 (#9846)
  web: bump chromedriver from 125.0.1 to 125.0.2 in /tests/wdio (#9847)
  web: bump @sentry/browser from 8.3.0 to 8.4.0 in /web in the sentry group (#9848)
  web: bump the storybook group in /web with 7 updates (#9849)
  ...
2024-05-24 13:06:15 -07:00
92911d1d0f core: add user directory
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-01-24 19:40:13 +01:00
16 changed files with 545 additions and 9 deletions

View File

@ -0,0 +1,99 @@
"""User directory API Views"""
from typing import Any
from drf_spectacular.utils import extend_schema, inline_serializer
from guardian.shortcuts import get_anonymous_user
from rest_framework.decorators import action
from rest_framework.fields import SerializerMethodField
from rest_framework.serializers import CharField, DictField, ListField, ModelSerializer
from rest_framework.views import Request, Response
from rest_framework.viewsets import ReadOnlyModelViewSet
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.rbac.permissions import HasPermission
from authentik.tenants.utils import get_current_tenant
LOGGER = get_logger()
class UserDirectorySerializer(ModelSerializer):
"""User Directory Serializer"""
user_fields = SerializerMethodField()
attributes = SerializerMethodField()
class Meta:
model = User
fields = [
"pk",
"user_fields",
"attributes",
]
def get_user_fields(self, obj: User) -> dict[str, Any]:
"""Get directory fields"""
fields = {}
user_directory_fields = get_current_tenant().user_directory_fields
for f in ("name", "username", "email", "avatar"):
if f in user_directory_fields:
fields[f] = getattr(obj, f)
if "groups" in user_directory_fields:
fields["groups"] = [g.name for g in obj.all_groups().order_by("name")]
return fields
def get_attributes(self, obj: User) -> dict[str, Any]:
"""Get directory attributes"""
attributes = {}
for field in get_current_tenant().user_directory_attributes:
path = field.get("attribute", None)
if path is not None:
attributes[path] = obj.attributes.get(path, None)
return attributes
class UserDirectoryViewSet(ReadOnlyModelViewSet):
"""User Directory Viewset"""
queryset = User.objects.none()
ordering = ["username"]
ordering_fields = ["username", "email", "name"]
serializer_class = UserDirectorySerializer
permission_classes = [HasPermission("authentik_rbac.view_user_directory")]
def get_queryset(self):
return User.objects.all().exclude(pk=get_anonymous_user().pk).filter(is_active=True)
@property
def search_fields(self):
"""Get search fields"""
current_tenant = get_current_tenant()
return list(
f for f in current_tenant.user_directory_fields if f not in ("avatar", "groups")
) + list(
f"attributes__{attr['attribute']}"
for attr in current_tenant.user_directory_attributes
if "attribute" in attr
)
@extend_schema(
responses={
200: inline_serializer(
"UserDirectoryFieldsSerializer",
{
"fields": ListField(child=CharField()),
"attributes": ListField(child=DictField(child=CharField())),
},
)
},
)
@action(detail=False, pagination_class=None)
def fields(self, request: Request) -> Response:
"""Get user directory fields"""
return Response(
{
"fields": request.tenant.user_directory_fields,
"attributes": request.tenant.user_directory_attributes,
}
)

View File

@ -17,6 +17,7 @@ from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.transactional_applications import TransactionalApplicationView
from authentik.core.api.user_directory import UserDirectoryViewSet
from authentik.core.api.users import UserViewSet
from authentik.core.views import apps
from authentik.core.views.debug import AccessDeniedView
@ -82,6 +83,7 @@ api_urlpatterns = [
),
("core/groups", GroupViewSet),
("core/users", UserViewSet),
("core/user_directory", UserDirectoryViewSet),
("core/tokens", TokenViewSet),
("sources/all", SourceViewSet),
("sources/user_connections/all", UserSourceConnectionViewSet),

View File

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_rbac", "0003_alter_systempermission_options"),
]
@ -17,6 +16,9 @@ class Migration(migrations.Migration):
"managed": False,
"permissions": [
("view_system_info", "Can view system info"),
("view_system_tasks", "Can view system tasks"),
("view_user_directory", "Can view users in the user directory"),
("run_system_tasks", "Can run system tasks"),
("access_admin_interface", "Can access admin interface"),
("view_system_settings", "Can view system settings"),
("edit_system_settings", "Can edit system settings"),

View File

@ -67,6 +67,9 @@ class SystemPermission(models.Model):
verbose_name_plural = _("System permissions")
permissions = [
("view_system_info", _("Can view system info")),
("view_system_tasks", _("Can view system tasks")),
("view_user_directory", _("Can view users in the user directory")),
("run_system_tasks", _("Can run system tasks")),
("access_admin_interface", _("Can access admin interface")),
("view_system_settings", _("Can view system settings")),
("edit_system_settings", _("Can edit system settings")),

View File

@ -23,6 +23,8 @@ class SettingsSerializer(ModelSerializer):
"footer_links",
"gdpr_compliance",
"impersonation",
"user_directory_fields",
"user_directory_attributes",
"default_token_duration",
"default_token_length",
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.0.1 on 2024-01-24 14:27
from django.db import migrations, models
import authentik.tenants.models
class Migration(migrations.Migration):
dependencies = [
("authentik_tenants", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="tenant",
name="user_directory_attributes",
field=models.JSONField(
blank=True, default=list, help_text="Attributes to show in the user directory."
),
),
migrations.AddField(
model_name="tenant",
name="user_directory_fields",
field=models.JSONField(
blank=True,
default=authentik.tenants.models._default_user_directory_fields,
help_text="Fields to show in the user directory.",
),
),
]

View File

@ -0,0 +1,13 @@
# Generated by Django 5.0.6 on 2024-05-24 18:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_tenants", "0002_tenant_user_directory_and_more"),
("authentik_tenants", "0003_alter_tenant_default_token_duration"),
]
operations = []

View File

@ -37,6 +37,10 @@ def _validate_schema_name(name):
)
def _default_user_directory_fields():
return ["avatar", "name", "username", "email", "groups"]
class Tenant(TenantMixin, SerializerModel):
"""Tenant"""
@ -85,6 +89,14 @@ class Tenant(TenantMixin, SerializerModel):
impersonation = models.BooleanField(
help_text=_("Globally enable/disable impersonation."), default=True
)
user_directory_fields = models.JSONField(
help_text=_("Fields to show in the user directory."),
default=_default_user_directory_fields,
blank=True,
)
user_directory_attributes = models.JSONField(
help_text=_("Attributes to show in the user directory."), default=list, blank=True
)
default_token_duration = models.TextField(
help_text=_("Default token duration"),
default=DEFAULT_TOKEN_DURATION,

View File

@ -4537,6 +4537,119 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/user_directory/:
get:
operationId: core_user_directory_list
description: User Directory Viewset
parameters:
- name: ordering
required: false
in: query
description: Which field to use when ordering the results.
schema:
type: string
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: search
required: false
in: query
description: A search term.
schema:
type: string
tags:
- core
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedUserDirectoryList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/user_directory/{id}/:
get:
operationId: core_user_directory_retrieve
description: User Directory Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User.
required: true
tags:
- core
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserDirectory'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/user_directory/fields/:
get:
operationId: core_user_directory_fields_retrieve
description: Get user directory fields
tags:
- core
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserDirectoryFields'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/users/:
get:
operationId: core_users_list
@ -40859,6 +40972,18 @@ components:
required:
- pagination
- results
PaginatedUserDirectoryList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/UserDirectory'
required:
- pagination
- results
PaginatedUserList:
type: object
properties:
@ -43614,6 +43739,10 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
user_directory_fields:
description: Fields to show in the user directory.
user_directory_attributes:
description: Attributes to show in the user directory.
default_token_duration:
type: string
minLength: 1
@ -46901,6 +47030,10 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
user_directory_fields:
description: Fields to show in the user directory.
user_directory_attributes:
description: Attributes to show in the user directory.
default_token_duration:
type: string
description: Default token duration
@ -46940,6 +47073,10 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
user_directory_fields:
description: Fields to show in the user directory.
user_directory_attributes:
description: Attributes to show in the user directory.
default_token_duration:
type: string
minLength: 1
@ -48032,6 +48169,44 @@ components:
$ref: '#/components/schemas/FlowSetRequest'
required:
- name
UserDirectory:
type: object
description: User Directory Serializer
properties:
pk:
type: integer
readOnly: true
title: ID
user_fields:
type: object
additionalProperties: {}
description: Get directory fields
readOnly: true
attributes:
type: object
additionalProperties: {}
description: Get directory attributes
readOnly: true
required:
- attributes
- pk
- user_fields
UserDirectoryFields:
type: object
properties:
fields:
type: array
items:
type: string
attributes:
type: array
items:
type: object
additionalProperties:
type: string
required:
- attributes
- fields
UserFieldsEnum:
enum:
- email

View File

@ -193,6 +193,42 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
help=${msg("Globally enable/disable impersonation.")}
>
</ak-switch-input>
<ak-form-element-horizontal
label=${msg("User directory fields")}
name="userDirectoryFields"
>
<ak-codemirror
mode=${CodeMirrorMode.YAML}
.value="${first(this._settings?.userDirectoryFields, [
"name",
"username",
"email",
"avatars",
"groups",
])}"
></ak-codemirror>
<p class="pf-c-form__helper-text">
${msg(
"This option configures what user fields are shown in the user directory. It must be a valid JSON list and can be used as follows, with all possible values included:",
)}
<code>["name", "username", "email", "avatars", "groups"]</code>
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("User directory attributes")}
name="userDirectoryAttributes"
>
<ak-codemirror
mode=${CodeMirrorMode.YAML}
.value="${first(this._settings?.userDirectoryAttributes, [])}"
></ak-codemirror>
<p class="pf-c-form__helper-text">
${msg(
"This option configures what user attributes are shown in the user directory. It must be a valid JSON list and can be used as follows:",
)}
<code>[{"attribute": "phone_number", "display_name": "Phone"}]</code>
</p>
</ak-form-element-horizontal>
<ak-text-input
name="defaultTokenDuration"
label=${msg("Default token duration")}

View File

@ -53,7 +53,7 @@ export class BlueprintListPage extends TablePage<BlueprintInstance> {
return msg("Automate and template configuration within authentik.");
}
pageIcon(): string {
return "pf-icon pf-icon-blueprint";
return "fa fa-user";
}
expandable = true;

View File

@ -141,6 +141,7 @@ export class PageHeader extends WithBrandConfig(AKElement) {
return html` <ak-enterprise-status interface="admin"></ak-enterprise-status>
<div class="bar">
<button
part="sidebar-trigger"
class="sidebar-trigger pf-c-button pf-m-plain"
@click=${() => {
this.dispatchEvent(

View File

@ -70,14 +70,17 @@ export abstract class TablePage<T> extends Table<T> {
</button>`;
}
render(): TemplateResult {
renderPageHeader(): TemplateResult {
return html`<ak-page-header
icon=${this.pageIcon()}
header=${this.pageTitle()}
description=${ifDefined(this.pageDescription())}
>
</ak-page-header>
${this.renderSectionBefore()}
icon=${this.pageIcon()}
header=${this.pageTitle()}
description=${ifDefined(this.pageDescription())}
>
</ak-page-header>`;
}
render(): TemplateResult {
return html`${this.renderPageHeader()} ${this.renderSectionBefore()}
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-sidebar pf-m-gutter">
<div class="pf-c-sidebar__main">

View File

@ -8,6 +8,10 @@ export const ROUTES: Route[] = [
new Route(new RegExp("^/$")).redirect("/library"),
new Route(new RegExp("^#.*")).redirect("/library"),
new Route(new RegExp("^/library$"), async () => html`<ak-library></ak-library>`),
new Route(new RegExp("^/directory"), async () => {
await import("@goauthentik/user/user-directory/UserDirectoryPage");
return html`<ak-user-directory></ak-user-directory>`;
}),
new Route(new RegExp("^/settings$"), async () => {
await import("@goauthentik/user/user-settings/UserSettingsPage");
return html`<ak-user-settings></ak-user-settings>`;

View File

@ -159,6 +159,13 @@ class UserInterfacePresentation extends AKElement {
.otherwise(() => this.me.user.username);
}
get canAccessUserDirectory() {
return (
this.me.user.isSuperuser ||
this.me.user.systemPermissions.includes("can_view_user_directory")
);
}
get canAccessAdmin() {
return (
this.me.user.isSuperuser ||
@ -204,6 +211,8 @@ class UserInterfacePresentation extends AKElement {
<!-- -->
${this.renderNotificationDrawerTrigger()}
<!-- -->
${this.renderUserDirectory()}
<!-- -->
${this.renderSettings()}
<div class="pf-c-page__header-tools-item">
<a
@ -355,6 +364,20 @@ class UserInterfacePresentation extends AKElement {
</a>`;
}
renderUserDirectory() {
if (!this.canAccessUserDirectory) {
return nothing;
}
return html` <div class="pf-c-page__header-tools-item">
<a class="pf-c-button pf-m-plain" type="button" href="#/directory">
<pf-tooltip position="top" content=${msg("User directory")}>
<i class="pf-icon pf-icon-project" aria-hidden="true"></i>
</pf-tooltip>
</a>
</div>`;
}
renderImpersonation() {
if (!this.me.original) {
return nothing;

View File

@ -0,0 +1,131 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { renderDescriptionList } from "@goauthentik/components/DescriptionList.js";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
import { msg } from "@lit/localize";
import { css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CoreApi, UserDirectory } from "@goauthentik/api";
const knownFields: Record<string, string> = {
avatar: "",
username: msg("Username"),
name: msg("Name"),
email: msg("Email"),
};
type UserFieldAttributes = { display_name: string; attribute: string };
@customElement("ak-user-directory")
export class UserDirectoryPage extends TablePage<UserDirectory> {
expandable = true;
searchEnabled(): boolean {
return true;
}
pageTitle(): string {
return msg("User Directory");
}
pageDescription(): string {
return msg("Display a list of users on this system.");
}
pageIcon(): string {
return "pf-icon pf-icon-project";
}
@property()
order = "username";
@state()
fields?: string[];
@state()
userFieldAttributes?: object[] = [];
static get styles() {
return [
...super.styles,
PFDescriptionList,
PFCard,
PFAlert,
PFAvatar,
css`
ak-page-header::part(sidebar-trigger) {
display: none;
}
`,
];
}
async apiEndpoint(): Promise<PaginatedResponse<UserDirectory>> {
const fields = await new CoreApi(DEFAULT_CONFIG).coreUserDirectoryFieldsRetrieve();
this.fields = fields.fields;
this.userFieldAttributes = fields.attributes;
return await new CoreApi(DEFAULT_CONFIG).coreUserDirectoryList(
await this.defaultEndpointConfig(),
);
}
columns() {
return (this.fields ?? [])
.filter((item) => item in knownFields)
.map((item) =>
item === "avatar"
? new TableColumn(knownFields[item])
: new TableColumn(knownFields[item], item),
);
}
row(item: UserDirectory) {
return (this.fields ?? [])
.filter((field: string) => Object.hasOwn(knownFields, field))
.map((field: string) =>
field !== "avatar"
? html`${item.userFields[field]}`
: html` <img
class="pf-c-avatar"
src=${item.userFields[field]}
alt="${msg("Avatar image")}"
/>`,
);
}
renderExpanded(item: UserDirectory) {
const groupDescription =
this.fields?.includes("groups") && (item.userFields["groups"] ?? []).length > 0
? [
[msg("Groups")],
item.userFields["groups"].map(
(group: string) => html`
<div class="pf-c-description-list__text">${group}</div>
`,
),
]
: [];
const userDescriptions = ((this.userFieldAttributes ?? []) as UserFieldAttributes[])
.filter(({ attribute }) => attribute !== null)
.map(({ display_name, attribute }) => [display_name, item.attributes[attribute]]);
const toShow = [...groupDescription, ...userDescriptions];
return toShow.length > 1
? html`<td role="cell" colspan="3">
<div class="pf-c-table__expandable-row-content">
${renderDescriptionList(toShow)}
</div>
</td>`
: html``;
}
}