diff --git a/authentik/admin/templates/administration/flow/list.html b/authentik/admin/templates/administration/flow/list.html index f1a27a0d3..5bb2dfcd4 100644 --- a/authentik/admin/templates/administration/flow/list.html +++ b/authentik/admin/templates/administration/flow/list.html @@ -53,10 +53,10 @@ {% for flow in object_list %} -
+
{{ flow.slug }}
{{ flow.name }} -
+ diff --git a/authentik/admin/templates/administration/source/list.html b/authentik/admin/templates/administration/source/list.html index a4a01442e..e9af3c44e 100644 --- a/authentik/admin/templates/administration/source/list.html +++ b/authentik/admin/templates/administration/source/list.html @@ -63,12 +63,12 @@ {% for source in object_list %} -
+
{{ source.name }}
{% if not source.enabled %} {% trans 'Disabled' %} {% endif %} -
+ diff --git a/authentik/admin/tests/__init__.py b/authentik/admin/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/admin/tests.py b/authentik/admin/tests/test_generated.py similarity index 100% rename from authentik/admin/tests.py rename to authentik/admin/tests/test_generated.py diff --git a/authentik/admin/tests/test_policy_binding.py b/authentik/admin/tests/test_policy_binding.py new file mode 100644 index 000000000..5a964bf81 --- /dev/null +++ b/authentik/admin/tests/test_policy_binding.py @@ -0,0 +1,26 @@ +"""admin tests""" +from django.test import TestCase +from django.test.client import RequestFactory + +from authentik.admin.views.policies_bindings import PolicyBindingCreateView +from authentik.core.models import Application + + +class TestPolicyBindingView(TestCase): + """Generic admin tests""" + + def setUp(self): + self.factory = RequestFactory() + + def test_without_get_param(self): + """Test PolicyBindingCreateView without get params""" + request = self.factory.get("/") + view = PolicyBindingCreateView(request=request) + self.assertEqual(view.get_initial(), {}) + + def test_with_param(self): + """Test PolicyBindingCreateView with get params""" + target = Application.objects.create(name="test") + request = self.factory.get("/", {"target": target.pk.hex}) + view = PolicyBindingCreateView(request=request) + self.assertEqual(view.get_initial(), {"target": target, "order": 0}) diff --git a/authentik/admin/tests/test_stage_bindings.py b/authentik/admin/tests/test_stage_bindings.py new file mode 100644 index 000000000..169048e4f --- /dev/null +++ b/authentik/admin/tests/test_stage_bindings.py @@ -0,0 +1,26 @@ +"""admin tests""" +from django.test import TestCase +from django.test.client import RequestFactory + +from authentik.admin.views.stages_bindings import StageBindingCreateView +from authentik.flows.models import Flow + + +class TestStageBindingView(TestCase): + """Generic admin tests""" + + def setUp(self): + self.factory = RequestFactory() + + def test_without_get_param(self): + """Test StageBindingCreateView without get params""" + request = self.factory.get("/") + view = StageBindingCreateView(request=request) + self.assertEqual(view.get_initial(), {}) + + def test_with_param(self): + """Test StageBindingCreateView with get params""" + target = Flow.objects.create(name="test", slug="test") + request = self.factory.get("/", {"target": target.pk.hex}) + view = StageBindingCreateView(request=request) + self.assertEqual(view.get_initial(), {"target": target, "order": 0}) diff --git a/authentik/admin/views/policies_bindings.py b/authentik/admin/views/policies_bindings.py index 9f7a8f97a..be19eb815 100644 --- a/authentik/admin/views/policies_bindings.py +++ b/authentik/admin/views/policies_bindings.py @@ -1,10 +1,12 @@ """authentik PolicyBinding administration""" +from typing import Any + from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import ( PermissionRequiredMixin as DjangoPermissionRequiredMixin, ) from django.contrib.messages.views import SuccessMessageMixin -from django.db.models import QuerySet +from django.db.models import Max, QuerySet from django.urls import reverse_lazy from django.utils.translation import gettext as _ from django.views.generic import ListView, UpdateView @@ -18,7 +20,7 @@ from authentik.admin.views.utils import ( ) from authentik.lib.views import CreateAssignPermView from authentik.policies.forms import PolicyBindingForm -from authentik.policies.models import PolicyBinding +from authentik.policies.models import PolicyBinding, PolicyBindingModel class PolicyBindingListView( @@ -67,6 +69,22 @@ class PolicyBindingCreateView( success_url = reverse_lazy("authentik_admin:policies-bindings") success_message = _("Successfully created PolicyBinding") + def get_initial(self) -> dict[str, Any]: + if "target" in self.request.GET: + initial_target_pk = self.request.GET["target"] + targets = PolicyBindingModel.objects.filter( + pk=initial_target_pk + ).select_subclasses() + if not targets.exists(): + return {} + max_order = PolicyBinding.objects.filter(target=targets.first()).aggregate( + Max("order") + )["order__max"] + if not isinstance(max_order, int): + max_order = -1 + return {"target": targets.first(), "order": max_order + 1} + return super().get_initial() + class PolicyBindingUpdateView( SuccessMessageMixin, diff --git a/authentik/admin/views/stages_bindings.py b/authentik/admin/views/stages_bindings.py index d048764e8..7e416d19f 100644 --- a/authentik/admin/views/stages_bindings.py +++ b/authentik/admin/views/stages_bindings.py @@ -1,9 +1,12 @@ """authentik StageBinding administration""" +from typing import Any + from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import ( PermissionRequiredMixin as DjangoPermissionRequiredMixin, ) from django.contrib.messages.views import SuccessMessageMixin +from django.db.models import Max from django.urls import reverse_lazy from django.utils.translation import gettext as _ from django.views.generic import ListView, UpdateView @@ -15,7 +18,7 @@ from authentik.admin.views.utils import ( UserPaginateListMixin, ) from authentik.flows.forms import FlowStageBindingForm -from authentik.flows.models import FlowStageBinding +from authentik.flows.models import Flow, FlowStageBinding from authentik.lib.views import CreateAssignPermView @@ -47,6 +50,20 @@ class StageBindingCreateView( success_url = reverse_lazy("authentik_admin:stage-bindings") success_message = _("Successfully created StageBinding") + def get_initial(self) -> dict[str, Any]: + if "target" in self.request.GET: + initial_target_pk = self.request.GET["target"] + targets = Flow.objects.filter(pk=initial_target_pk).select_subclasses() + if not targets.exists(): + return {} + max_order = FlowStageBinding.objects.filter( + target=targets.first() + ).aggregate(Max("order"))["order__max"] + if not isinstance(max_order, int): + max_order = -1 + return {"target": targets.first(), "order": max_order + 1} + return super().get_initial() + class StageBindingUpdateView( SuccessMessageMixin, diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index e19acf27e..120dd8ac1 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -15,6 +15,12 @@ class SourceSerializer(ModelSerializer): """Get object type so that we know which API Endpoint to use to get the full object""" return obj._meta.object_name.lower().replace("source", "") + def to_representation(self, instance: Source): + # pyright: reportGeneralTypeIssues=false + if instance.__class__ == Source: + return super().to_representation(instance) + return instance.serializer(instance=instance).data + class Meta: model = Source @@ -26,6 +32,7 @@ class SourceViewSet(ReadOnlyModelViewSet): queryset = Source.objects.all() serializer_class = SourceSerializer + lookup_field = "slug" def get_queryset(self): return Source.objects.select_subclasses() diff --git a/authentik/core/models.py b/authentik/core/models.py index b55acac51..d1fbd9d67 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -20,7 +20,7 @@ from authentik.core.exceptions import PropertyMappingExpressionException from authentik.core.signals import password_changed from authentik.core.types import UILoginButton from authentik.flows.models import Flow -from authentik.lib.models import CreatedUpdatedModel +from authentik.lib.models import CreatedUpdatedModel, SerializerModel from authentik.policies.models import PolicyBindingModel LOGGER = get_logger() @@ -200,7 +200,7 @@ class Application(PolicyBindingModel): verbose_name_plural = _("Applications") -class Source(PolicyBindingModel): +class Source(SerializerModel, PolicyBindingModel): """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" name = models.TextField(help_text=_("Source's display Name.")) diff --git a/authentik/core/templates/generic/delete.html b/authentik/core/templates/generic/delete.html index 2cfab27f2..234dab40e 100644 --- a/authentik/core/templates/generic/delete.html +++ b/authentik/core/templates/generic/delete.html @@ -20,7 +20,7 @@
-
+ {% csrf_token %}

{% blocktrans with object_type=object|verbose_name name=object %} @@ -35,7 +35,7 @@

{% endblock %} diff --git a/authentik/core/templates/login/base.html b/authentik/core/templates/login/base.html index 401c5c1cd..c029b284c 100644 --- a/authentik/core/templates/login/base.html +++ b/authentik/core/templates/login/base.html @@ -1,7 +1,7 @@ {% load static %} {% load i18n %} - +
- +
@@ -56,21 +70,59 @@ export abstract class Table extends LitElement { `; } + renderEmpty(inner?: TemplateResult): TemplateResult { + return html` + + +
+ ${inner ? inner : html``} +
+ + + `; + } + private renderRows(): TemplateResult[] | undefined { if (!this.data) { return; } - return this.data.results.map((item) => { - const fullRow = [""].concat( - this.row(item).map((col) => { - return `${col}`; - }) - ); - fullRow.push(""); - return htmlFromString(...fullRow); + if (this.data.pagination.count === 0) { + return [this.renderEmpty()]; + } + return this.data.results.map((item: T, idx: number) => { + if ((this.expandedRows.length - 1) < idx) { + this.expandedRows[idx] = false; + } + return html` + + ${this.expandable ? html` + + ` : html``} + ${this.row(item).map((col) => { + return html`${col}`; + })} + + + + ${this.renderExpanded(item)} + + `; }); } + renderToolbar(): TemplateResult { + return html` `; + } + renderTable(): TemplateResult { if (!this.data) { this.fetch(); @@ -78,12 +130,7 @@ export abstract class Table extends LitElement { return html`
- - + ${this.renderToolbar()}
extends LitElement {
- +
+ ${this.expandable ? html``)} - - ${this.data ? this.renderRows() : this.renderLoading()} - + ${this.data ? this.renderRows() : this.renderLoading()}
` : html``} ${this.columns().map((col) => html`${gettext(col)}
- +
Skip to content => { - return User.me().then(u => u.is_superuser); - }, - }, - { - name: "Administration", - children: [ - { - name: "Overview", - path: ["/administration/overview-ng/"], - }, - { - name: "System Tasks", - path: ["/administration/tasks/"], - }, - { - name: "Applications", - path: ["/administration/applications/"], - }, - { - name: "Sources", - path: ["/administration/sources/"], - }, - { - name: "Providers", - path: ["/administration/providers/"], - }, - { - name: "User Management", - children: [ - { - name: "User", - path: ["/administration/users/"], - }, - { - name: "Groups", - path: ["/administration/groups/"], - }, - ], - }, - { - name: "Outposts", - children: [ - { - name: "Outposts", - path: ["/administration/outposts/"], - }, - { - name: "Service Connections", - path: ["/administration/outposts/service_connections/"], - }, - ], - }, - { - name: "Policies", - children: [ - { - name: "Policies", - path: ["/administration/policies/"], - }, - { - name: "Bindings", - path: ["/administration/policies/bindings/"], - }, - ], - }, - { - name: "Property Mappings", - path: ["/administration/property-mappings/"], - }, - { - name: "Flows", - children: [ - { - name: "Flows", - path: ["/administration/flows/"], - }, - { - name: "Stages", - path: ["/administration/stages/"], - }, - { - name: "Prompts", - path: ["/administration/stages/prompts/"], - }, - { - name: "Invitations", - path: ["/administration/stages/invitations/"], - }, - ], - }, - { - name: "Certificates", - path: ["/administration/crypto/certificates/"], - }, - { - name: "Tokens", - path: ["/administration/tokens/"], - }, - ], - condition: (): Promise => { - return User.me().then(u => u.is_superuser); - }, - }, + new SidebarItem("Library", "/library/"), + new SidebarItem("Monitor", "/audit/audit").when((): Promise => { + return User.me().then(u => u.is_superuser); + }), + new SidebarItem("Administration").children( + new SidebarItem("Overview", "/administration/overview-ng/"), + new SidebarItem("System Tasks", "/administration/tasks/"), + new SidebarItem("Applications", "/administration/applications/").activeWhen( + `^/applications/(?${SLUG_REGEX})/$` + ), + new SidebarItem("Sources", "/administration/sources/").activeWhen( + `^/sources/(?${SLUG_REGEX})/$`, + ), + new SidebarItem("Providers", "/administration/providers/"), + new SidebarItem("Flows").children( + new SidebarItem("Flows", "/administration/flows/").activeWhen(`^/flows/(?${SLUG_REGEX})/$`), + new SidebarItem("Stages", "/administration/stages/"), + new SidebarItem("Prompts", "/administration/stages/prompts/"), + new SidebarItem("Invitations", "/administration/stages/invitations/"), + ), + new SidebarItem("User Management").children( + new SidebarItem("User", "/administration/users/"), + new SidebarItem("Groups", "/administration/groups/") + ), + new SidebarItem("Outposts").children( + new SidebarItem("Outposts", "/administration/outposts/"), + new SidebarItem("Service Connections", "/administration/outposts/service_connections/") + ), + new SidebarItem("Policies", "/administration/policies/"), + new SidebarItem("Property Mappings", "/administration/property-mappings"), + new SidebarItem("Certificates", "/administration/crypto/certificates"), + new SidebarItem("Tokens", "/administration/tokens/"), + ).when((): Promise => { + return User.me().then(u => u.is_superuser); + }) ]; @customElement("ak-interface-admin") diff --git a/web/src/interfaces/Interface.ts b/web/src/interfaces/Interface.ts index 44c42724d..44bf02799 100644 --- a/web/src/interfaces/Interface.ts +++ b/web/src/interfaces/Interface.ts @@ -3,7 +3,7 @@ import { html, LitElement, TemplateResult } from "lit-element"; import { SidebarItem } from "../elements/sidebar/Sidebar"; import "../elements/Messages"; -import "../pages/router/RouterOutlet"; +import "../elements/router/RouterOutlet"; export abstract class Interface extends LitElement { diff --git a/web/src/main.ts b/web/src/main.ts index 134d18994..8fb2f45c2 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -13,18 +13,18 @@ import "./elements/sidebar/SidebarUser"; import "./elements/table/TablePagination"; import "./elements/AdminLoginsChart"; +import "./elements/EmptyState"; import "./elements/cards/AggregateCard"; import "./elements/cards/AggregatePromiseCard"; import "./elements/CodeMirror"; import "./elements/Messages"; import "./elements/Spinner"; import "./elements/Tabs"; +import "./elements/router/RouterOutlet"; import "./pages/generic/FlowShellCard"; import "./pages/generic/SiteShell"; -import "./pages/router/RouterOutlet"; - import "./pages/admin-overview/AdminOverviewPage"; import "./pages/admin-overview/TopApplicationsTable"; import "./pages/applications/ApplicationListPage"; diff --git a/web/src/pages/applications/ApplicationListPage.ts b/web/src/pages/applications/ApplicationListPage.ts index e8d190f97..5ab0e17e5 100644 --- a/web/src/pages/applications/ApplicationListPage.ts +++ b/web/src/pages/applications/ApplicationListPage.ts @@ -1,9 +1,12 @@ import { gettext } from "django"; -import { customElement } from "lit-element"; +import { customElement, html, TemplateResult } from "lit-element"; import { Application } from "../../api/application"; import { PBResponse } from "../../api/client"; import { TablePage } from "../../elements/table/TablePage"; +import "../../elements/buttons/ModalButton"; +import "../../elements/buttons/SpinnerButton"; + @customElement("ak-application-list") export class ApplicationList extends TablePage { pageTitle(): string { @@ -27,13 +30,13 @@ export class ApplicationList extends TablePage { return ["Name", "Slug", "Provider", "Provider Type", ""]; } - row(item: Application): string[] { + row(item: Application): TemplateResult[] { return [ - item.name, - item.slug, - item.provider.toString(), - item.provider.toString(), - ` + html`${item.name}`, + html`${item.slug}`, + html`${item.provider}`, + html`${item.provider}`, + html` Edit diff --git a/web/src/pages/applications/ApplicationViewPage.ts b/web/src/pages/applications/ApplicationViewPage.ts index 12e35095d..dd0be9255 100644 --- a/web/src/pages/applications/ApplicationViewPage.ts +++ b/web/src/pages/applications/ApplicationViewPage.ts @@ -1,54 +1,14 @@ import { gettext } from "django"; import { css, CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element"; import { Application } from "../../api/application"; -import { DefaultClient, PBResponse } from "../../api/client"; -import { PolicyBinding } from "../../api/policy_binding"; +import { DefaultClient } from "../../api/client"; import { COMMON_STYLES } from "../../common/styles"; -import { Table } from "../../elements/table/Table"; import "../../elements/Tabs"; import "../../elements/AdminLoginsChart"; - -@customElement("ak-bound-policies-list") -export class BoundPoliciesList extends Table { - @property() - target?: string; - - apiEndpoint(page: number): Promise> { - return DefaultClient.fetch>(["policies", "bindings"], { - target: this.target || "", - ordering: "order", - page: page, - }); - } - - columns(): string[] { - return ["Policy", "Enabled", "Order", "Timeout", ""]; - } - - row(item: PolicyBinding): string[] { - return [ - item.policy_obj.name, - item.enabled ? "Yes" : "No", - item.order.toString(), - item.timeout.toString(), - ` - - - Edit - -
-
- - - Delete - -
-
- `, - ]; - } -} +import "../../elements/buttons/ModalButton"; +import "../../elements/buttons/SpinnerButton"; +import "../../elements/policies/BoundPoliciesList"; @customElement("ak-application-view") export class ApplicationViewPage extends LitElement { @@ -108,7 +68,13 @@ export class ApplicationViewPage extends LitElement {
- +
+
+ ${gettext("These policies control which users can access this application.")} +
+
+ +
`; diff --git a/web/src/pages/flows/BoundStagesList.ts b/web/src/pages/flows/BoundStagesList.ts new file mode 100644 index 000000000..d5e73148f --- /dev/null +++ b/web/src/pages/flows/BoundStagesList.ts @@ -0,0 +1,97 @@ +import { gettext } from "django"; +import { customElement, html, property, TemplateResult } from "lit-element"; +import { PBResponse } from "../../api/client"; +import { Table } from "../../elements/table/Table"; + +import "../../elements/Tabs"; +import "../../elements/AdminLoginsChart"; +import "../../elements/buttons/ModalButton"; +import "../../elements/buttons/SpinnerButton"; +import "../../elements/policies/BoundPoliciesList"; +import { FlowStageBinding } from "../../api/flow"; + +@customElement("ak-bound-stages-list") +export class BoundStagesList extends Table { + expandable = true; + + @property() + target?: string; + + apiEndpoint(page: number): Promise> { + return FlowStageBinding.list({ + target: this.target || "", + ordering: "order", + page: page, + }); + } + + columns(): string[] { + return ["Order", "Name", "Type", ""]; + } + + row(item: FlowStageBinding): TemplateResult[] { + return [ + html`${item.order}`, + html`${item.stage_obj.name}`, + html`${item.stage_obj.verbose_name}`, + html` + + + Edit + +
+
+ + + Delete + +
+
+ `, + ]; + } + + renderExpanded(item: FlowStageBinding): TemplateResult { + return html` + + +
+
+

${gettext("These policies control when this stage will be applied to the flow.")}

+ + +
+
+ + + `; + } + + renderEmpty(): TemplateResult { + return super.renderEmpty(html` +
+ ${gettext("No stages are currently bound to this flow.")} +
+
+ + + ${gettext("Bind Stage")} + +
+
+
+
`); + } + + renderToolbar(): TemplateResult { + return html` + + + ${gettext("Bind Stage")} + +
+
+ ${super.renderToolbar()} + `; + } +} diff --git a/web/src/pages/flows/FlowViewPage.ts b/web/src/pages/flows/FlowViewPage.ts new file mode 100644 index 000000000..dd7df45f9 --- /dev/null +++ b/web/src/pages/flows/FlowViewPage.ts @@ -0,0 +1,71 @@ +import { gettext } from "django"; +import { css, CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element"; +import { COMMON_STYLES } from "../../common/styles"; +import { Flow } from "../../api/flow"; + +import "../../elements/Tabs"; +import "../../elements/AdminLoginsChart"; +import "../../elements/buttons/ModalButton"; +import "../../elements/buttons/SpinnerButton"; +import "../../elements/policies/BoundPoliciesList"; +import "./BoundStagesList"; + +@customElement("ak-flow-view") +export class FlowViewPage extends LitElement { + @property() + set args(value: { [key: string]: string }) { + this.flowSlug = value.slug; + } + + @property() + set flowSlug(value: string) { + Flow.get(value).then((flow) => (this.flow = flow)); + } + + @property({attribute: false}) + flow?: Flow; + + static get styles(): CSSResult[] { + return COMMON_STYLES.concat( + css` + img.pf-icon { + max-height: 24px; + } + ` + ); + } + + render(): TemplateResult { + if (!this.flow) { + return html``; + } + return html`
+
+

+ + ${this.flow?.name} +

+

${this.flow?.title}

+
+
+ +
+
+ + +
+
+
+
+
+
+ ${gettext("These policies control which users can access this flow.")} +
+
+ + +
+
+
`; + } +} diff --git a/web/src/pages/sources/SourceViewPage.ts b/web/src/pages/sources/SourceViewPage.ts new file mode 100644 index 000000000..89fde1e08 --- /dev/null +++ b/web/src/pages/sources/SourceViewPage.ts @@ -0,0 +1,63 @@ +import { gettext } from "django"; +import { css, CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element"; +import { COMMON_STYLES } from "../../common/styles"; + +import "../../elements/Tabs"; +import "../../elements/AdminLoginsChart"; +import "../../elements/buttons/ModalButton"; +import "../../elements/buttons/SpinnerButton"; +import "../../elements/policies/BoundPoliciesList"; +import { Source } from "../../api/source"; + +@customElement("ak-source-view") +export class SourceViewPage extends LitElement { + @property() + set args(value: { [key: string]: string }) { + this.sourceSlug = value.slug; + } + + @property() + set sourceSlug(value: string) { + Source.get(value).then((source) => (this.source = source)); + } + + @property({attribute: false}) + source?: Source; + + static get styles(): CSSResult[] { + return COMMON_STYLES.concat( + css` + img.pf-icon { + max-height: 24px; + } + ` + ); + } + + render(): TemplateResult { + if (!this.source) { + return html``; + } + return html`
+
+

+ + ${this.source?.name} +

+
+
+ +
+
+
+
+ ${gettext("These policies control which users can access this application.")} +
+
+ + +
+
+
`; + } +} diff --git a/web/src/routes.ts b/web/src/routes.ts index cc5c29ea0..9c1620fde 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -1,10 +1,12 @@ import { html } from "lit-html"; -import { Route, SLUG_REGEX } from "./pages/router/Route"; +import { Route, SLUG_REGEX } from "./elements/router/Route"; import "./pages/LibraryPage"; import "./pages/admin-overview/AdminOverviewPage"; import "./pages/applications/ApplicationListPage"; import "./pages/applications/ApplicationViewPage"; +import "./pages/sources/SourceViewPage"; +import "./pages/flows/FlowViewPage"; export const ROUTES: Route[] = [ // Prevent infinite Shell loops @@ -16,4 +18,10 @@ export const ROUTES: Route[] = [ new Route(new RegExp(`^/applications/(?${SLUG_REGEX})/$`)).then((args) => { return html``; }), + new Route(new RegExp(`^/sources/(?${SLUG_REGEX})/$`)).then((args) => { + return html``; + }), + new Route(new RegExp(`^/flows/(?${SLUG_REGEX})/$`)).then((args) => { + return html``; + }), ];