web: Table parity (#427)

* core: fix application API always being sorted by name

* web: add sorting to tables

* web: add search to TablePage

* core: add search to applications API

* core: add MetaNameSerializer

* *: fix signature for non-modal serializers

* providers/*: implement MetaNameSerializer

* web: implement full app list page, use as default in sidebar

* web: fix linting errors

* admin: remove old application list

* web: fix default sorting for application list

* web: fix spacing for search element in toolbar
This commit is contained in:
Jens L 2020-12-24 09:56:05 +01:00 committed by GitHub
parent c3e9168b46
commit 79da2bf698
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 355 additions and 244 deletions

View file

@ -4,10 +4,9 @@ from collections import Counter
from datetime import timedelta from datetime import timedelta
from typing import Dict, List from typing import Dict, List
from django.db.models import Count, ExpressionWrapper, F from django.db.models import Count, ExpressionWrapper, F, Model
from django.db.models.fields import DurationField from django.db.models.fields import DurationField
from django.db.models.functions import ExtractHour from django.db.models.functions import ExtractHour
from django.http import response
from django.utils.timezone import now from django.utils.timezone import now
from drf_yasg2.utils import swagger_auto_schema from drf_yasg2.utils import swagger_auto_schema
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
@ -60,10 +59,10 @@ class AdministrationMetricsSerializer(Serializer):
"""Get failed logins per hour for the last 24 hours""" """Get failed logins per hour for the last 24 hours"""
return get_events_per_1h(action=EventAction.LOGIN_FAILED) return get_events_per_1h(action=EventAction.LOGIN_FAILED)
def create(self, request: Request) -> response: def create(self, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError
def update(self, request: Request) -> Response: def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError

View file

@ -2,6 +2,7 @@
from importlib import import_module from importlib import import_module
from django.contrib import messages from django.contrib import messages
from django.db.models import Model
from django.http.response import Http404 from django.http.response import Http404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from drf_yasg2.utils import swagger_auto_schema from drf_yasg2.utils import swagger_auto_schema
@ -26,10 +27,10 @@ class TaskSerializer(Serializer):
status = IntegerField(source="result.status.value") status = IntegerField(source="result.status.value")
messages = ListField(source="result.messages") messages = ListField(source="result.messages")
def create(self, request: Request) -> Response: def create(self, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError
def update(self, request: Request) -> Response: def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError

View file

@ -1,5 +1,6 @@
"""authentik administration overview""" """authentik administration overview"""
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema from drf_yasg2.utils import swagger_auto_schema
from packaging.version import parse from packaging.version import parse
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
@ -39,10 +40,10 @@ class VersionSerializer(Serializer):
self.get_version_latest(instance) self.get_version_latest(instance)
) )
def create(self, request: Request) -> Response: def create(self, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError
def update(self, request: Request) -> Response: def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError

View file

@ -1,131 +0,0 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load authentik_utils %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="pf-icon pf-icon-applications"></i>
{% trans 'Applications' %}
</h1>
<p>{% trans "External Applications which use authentik as Identity-Provider, utilizing protocols like OAuth2 and SAML." %}</p>
</div>
</section>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
{% if object_list %}
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
{% include 'partials/toolbar_search.html' %}
<div class="pf-c-toolbar__bulk-select">
<ak-modal-button href="{% url 'authentik_admin:application-create' %}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
{% trans 'Create' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<button role="ak-refresh" class="pf-c-button pf-m-primary">
{% trans 'Refresh' %}
</button>
</div>
{% include 'partials/pagination.html' %}
</div>
</div>
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
<thead>
<tr role="row">
<th role="columnheader"></th>
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
<th role="columnheader" scope="col">{% trans 'Slug' %}</th>
<th role="columnheader" scope="col">{% trans 'Provider' %}</th>
<th role="columnheader" scope="col">{% trans 'Provider Type' %}</th>
<th role="columnheader"></th>
</tr>
</thead>
<tbody role="rowgroup">
{% for application in object_list %}
<tr role="row">
<td role="cell" {% if application.meta_icon %} style="vertical-align: bottom;" {% endif %}>
{% if application.meta_icon %}
<img class="app-icon pf-c-avatar" src="{{ application.meta_icon.url }}" alt="{% trans 'Application Icon' %}">
{% else %}
<i class="pf-icon pf-icon-arrow"></i>
{% endif %}
</td>
<td role="cell">
<a href="/applications/{{ application.slug }}/">
<div>
{{ application.name }}
</div>
{% if application.meta_publisher %}
<small>{{ application.meta_publisher }}</small>
{% endif %}
</a>
</td>
<td role="cell">
<code>{{ application.slug }}</span>
</td>
<td role="cell">
<span>
{{ application.get_provider }}
</span>
</td>
<td role="cell">
<span>
{{ application.get_provider|verbose_name }}
</span>
</td>
<td>
<ak-modal-button href="{% url 'authentik_admin:application-update' pk=application.pk %}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
{% trans 'Edit' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-modal-button href="{% url 'authentik_admin:application-delete' pk=application.pk %}">
<ak-spinner-button slot="trigger" class="pf-m-danger">
{% trans 'Delete' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pf-c-pagination pf-m-bottom">
{% include 'partials/pagination.html' %}
</div>
{% else %}
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
{% include 'partials/toolbar_search.html' %}
</div>
</div>
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<i class="pf-icon pf-icon-applications pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">
{% trans 'No Applications.' %}
</h1>
<div class="pf-c-empty-state__body">
{% if request.GET.search != "" %}
{% trans "Your search query doesn't match any application." %}
{% else %}
{% trans 'Currently no applications exist. Click the button below to create one.' %}
{% endif %}
</div>
<ak-modal-button href="{% url 'authentik_admin:application-create' %}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
{% trans 'Create' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</div>
</div>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -35,9 +35,6 @@ urlpatterns = [
name="overview-clear-policy-cache", name="overview-clear-policy-cache",
), ),
# Applications # Applications
path(
"applications/", applications.ApplicationListView.as_view(), name="applications"
),
path( path(
"applications/create/", "applications/create/",
applications.ApplicationCreateView.as_view(), applications.ApplicationCreateView.as_view(),

View file

@ -6,44 +6,18 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView from django.views.generic import UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin from guardian.mixins import PermissionRequiredMixin
from authentik.admin.views.utils import ( from authentik.admin.views.utils import (
BackSuccessUrlMixin, BackSuccessUrlMixin,
DeleteMessageView, DeleteMessageView,
SearchListMixin,
UserPaginateListMixin,
) )
from authentik.core.forms.applications import ApplicationForm from authentik.core.forms.applications import ApplicationForm
from authentik.core.models import Application from authentik.core.models import Application
from authentik.lib.views import CreateAssignPermView from authentik.lib.views import CreateAssignPermView
class ApplicationListView(
LoginRequiredMixin,
PermissionListMixin,
UserPaginateListMixin,
SearchListMixin,
ListView,
):
"""Show list of all applications"""
model = Application
permission_required = "authentik_core.view_application"
ordering = "name"
template_name = "administration/application/list.html"
search_fields = [
"name",
"slug",
"meta_launch_url",
"meta_icon_url",
"meta_description",
"meta_publisher",
]
class ApplicationCreateView( class ApplicationCreateView(
SuccessMessageMixin, SuccessMessageMixin,
BackSuccessUrlMixin, BackSuccessUrlMixin,

View file

@ -1,4 +1,5 @@
"""core Configs API""" """core Configs API"""
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema from drf_yasg2.utils import swagger_auto_schema
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.request import Request from rest_framework.request import Request
@ -19,10 +20,10 @@ class ConfigSerializer(Serializer):
error_reporting_environment = ReadOnlyField() error_reporting_environment = ReadOnlyField()
error_reporting_send_pii = ReadOnlyField() error_reporting_send_pii = ReadOnlyField()
def create(self, request: Request) -> Response: def create(self, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError
def update(self, request: Request) -> Response: def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError

View file

@ -1,5 +1,6 @@
"""core messages API""" """core messages API"""
from django.contrib.messages import get_messages from django.contrib.messages import get_messages
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema from drf_yasg2.utils import swagger_auto_schema
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.request import Request from rest_framework.request import Request
@ -17,10 +18,10 @@ class MessageSerializer(Serializer):
extra_tags = ReadOnlyField() extra_tags = ReadOnlyField()
level_tag = ReadOnlyField() level_tag = ReadOnlyField()
def create(self, request: Request) -> Response: def create(self, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError
def update(self, request: Request) -> Response: def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError raise NotImplementedError

View file

@ -12,6 +12,7 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.admin.api.metrics import get_events_per_1h from authentik.admin.api.metrics import get_events_per_1h
from authentik.core.api.providers import ProviderSerializer
from authentik.core.models import Application from authentik.core.models import Application
from authentik.events.models import EventAction from authentik.events.models import EventAction
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
@ -21,6 +22,7 @@ class ApplicationSerializer(ModelSerializer):
"""Application Serializer""" """Application Serializer"""
launch_url = SerializerMethodField() launch_url = SerializerMethodField()
provider = ProviderSerializer(source="get_provider")
def get_launch_url(self, instance: Application) -> str: def get_launch_url(self, instance: Application) -> str:
"""Get generated launch URL""" """Get generated launch URL"""
@ -48,7 +50,15 @@ class ApplicationViewSet(ModelViewSet):
queryset = Application.objects.all() queryset = Application.objects.all()
serializer_class = ApplicationSerializer serializer_class = ApplicationSerializer
search_fields = [
"name",
"slug",
"meta_launch_url",
"meta_description",
"meta_publisher",
]
lookup_field = "slug" lookup_field = "slug"
ordering = ["name"]
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting""" """Custom filter_queryset method which ignores guardian, but still supports sorting"""
@ -63,7 +73,7 @@ class ApplicationViewSet(ModelViewSet):
queryset = self._filter_queryset_for_list(self.get_queryset()) queryset = self._filter_queryset_for_list(self.get_queryset())
self.paginate_queryset(queryset) self.paginate_queryset(queryset)
allowed_applications = [] allowed_applications = []
for application in queryset.order_by("name"): for application in queryset:
engine = PolicyEngine(application, self.request.user, self.request) engine = PolicyEngine(application, self.request.user, self.request)
engine.build() engine.build()
if engine.passing: if engine.passing:

View file

@ -2,15 +2,16 @@
from rest_framework.serializers import ModelSerializer, SerializerMethodField from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.core.models import Provider from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer): class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer""" """Provider Serializer"""
__type__ = SerializerMethodField(method_name="get_type") object_type = SerializerMethodField()
def get_type(self, obj): def get_object_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object""" """Get object type so that we know which API Endpoint to use to get the full object"""
return obj._meta.object_name.lower().replace("provider", "") return obj._meta.object_name.lower().replace("provider", "")
@ -29,7 +30,9 @@ class ProviderSerializer(ModelSerializer):
"application", "application",
"authorization_flow", "authorization_flow",
"property_mappings", "property_mappings",
"__type__", "object_type",
"verbose_name",
"verbose_name_plural",
] ]

View file

@ -0,0 +1,24 @@
"""API Utilities"""
from django.db.models import Model
from rest_framework.serializers import Serializer, SerializerMethodField
class MetaNameSerializer(Serializer):
"""Add verbose names to response"""
verbose_name = SerializerMethodField()
verbose_name_plural = SerializerMethodField()
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
def get_verbose_name(self, obj: Model) -> str:
"""Return object's verbose_name"""
return obj._meta.verbose_name
def get_verbose_name_plural(self, obj: Model) -> str:
"""Return object's plural verbose_name"""
return obj._meta.verbose_name_plural

View file

@ -2,10 +2,11 @@
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
class OAuth2ProviderSerializer(ModelSerializer): class OAuth2ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""OAuth2Provider Serializer""" """OAuth2Provider Serializer"""
class Meta: class Meta:
@ -25,6 +26,8 @@ class OAuth2ProviderSerializer(ModelSerializer):
"redirect_uris", "redirect_uris",
"sub_mode", "sub_mode",
"property_mappings", "property_mappings",
"verbose_name",
"verbose_name_plural",
] ]

View file

@ -6,6 +6,7 @@ from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.providers.oauth2.views.provider import ProviderInfoView from authentik.providers.oauth2.views.provider import ProviderInfoView
from authentik.providers.proxy.models import ProxyProvider from authentik.providers.proxy.models import ProxyProvider
@ -33,7 +34,7 @@ class OpenIDConnectConfigurationSerializer(Serializer):
raise NotImplementedError raise NotImplementedError
class ProxyProviderSerializer(ModelSerializer): class ProxyProviderSerializer(MetaNameSerializer, ModelSerializer):
"""ProxyProvider Serializer""" """ProxyProvider Serializer"""
def create(self, validated_data): def create(self, validated_data):
@ -60,6 +61,8 @@ class ProxyProviderSerializer(ModelSerializer):
"basic_auth_enabled", "basic_auth_enabled",
"basic_auth_password_attribute", "basic_auth_password_attribute",
"basic_auth_user_attribute", "basic_auth_user_attribute",
"verbose_name",
"verbose_name_plural",
] ]

View file

@ -2,10 +2,11 @@
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
class SAMLProviderSerializer(ModelSerializer): class SAMLProviderSerializer(ModelSerializer, MetaNameSerializer):
"""SAMLProvider Serializer""" """SAMLProvider Serializer"""
class Meta: class Meta:
@ -25,6 +26,8 @@ class SAMLProviderSerializer(ModelSerializer):
"signature_algorithm", "signature_algorithm",
"signing_kp", "signing_kp",
"verification_kp", "verification_kp",
"verbose_name",
"verbose_name_plural",
] ]

View file

@ -148,9 +148,9 @@ class SAMLProvider(Provider):
@property @property
def serializer(self) -> Type[Serializer]: def serializer(self) -> Type[Serializer]:
from authentik.providers.saml.api import SAMLPropertyMappingSerializer from authentik.providers.saml.api import SAMLProviderSerializer
return SAMLPropertyMappingSerializer return SAMLProviderSerializer
@property @property
def form(self) -> Type[ModelForm]: def form(self) -> Type[ModelForm]:

View file

@ -6729,11 +6729,55 @@ definitions:
title: Outdated title: Outdated
type: boolean type: boolean
readOnly: true readOnly: true
Provider:
title: Provider
description: Provider Serializer
required:
- name
- application
- authorization_flow
type: object
properties:
pk:
title: ID
type: integer
readOnly: true
name:
title: Name
type: string
minLength: 1
application:
title: Application
type: string
authorization_flow:
title: Authorization flow
description: Flow used when authorizing this provider.
type: string
format: uuid
property_mappings:
type: array
items:
type: string
format: uuid
uniqueItems: true
object_type:
title: Object type
type: string
readOnly: true
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
Application: Application:
description: Application Serializer description: Application Serializer
required: required:
- name - name
- slug - slug
- provider
type: object type: object
properties: properties:
pk: pk:
@ -6755,9 +6799,7 @@ definitions:
maxLength: 50 maxLength: 50
minLength: 1 minLength: 1
provider: provider:
title: Provider $ref: '#/definitions/Provider'
type: integer
x-nullable: true
launch_url: launch_url:
title: Launch url title: Launch url
type: string type: string
@ -7720,40 +7762,6 @@ definitions:
title: Expression title: Expression
type: string type: string
minLength: 1 minLength: 1
Provider:
description: Provider Serializer
required:
- name
- application
- authorization_flow
type: object
properties:
pk:
title: ID
type: integer
readOnly: true
name:
title: Name
type: string
minLength: 1
application:
title: Application
type: string
authorization_flow:
title: Authorization flow
description: Flow used when authorizing this provider.
type: string
format: uuid
property_mappings:
type: array
items:
type: string
format: uuid
uniqueItems: true
__type__:
title: 'type '
type: string
readOnly: true
OAuth2Provider: OAuth2Provider:
description: OAuth2Provider Serializer description: OAuth2Provider Serializer
required: required:
@ -7845,6 +7853,14 @@ definitions:
type: string type: string
format: uuid format: uuid
uniqueItems: true uniqueItems: true
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
ProxyProvider: ProxyProvider:
description: ProxyProvider Serializer description: ProxyProvider Serializer
required: required:
@ -7898,6 +7914,14 @@ definitions:
description: User/Group Attribute used for the user part of the HTTP-Basic description: User/Group Attribute used for the user part of the HTTP-Basic
Header. If not set, the user's Email address is used. Header. If not set, the user's Email address is used.
type: string type: string
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
SAMLProvider: SAMLProvider:
description: SAMLProvider Serializer description: SAMLProvider Serializer
required: required:
@ -7984,6 +8008,14 @@ definitions:
type: string type: string
format: uuid format: uuid
x-nullable: true x-nullable: true
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
Config: Config:
description: Serialize authentik Config into DRF Object description: Serialize authentik Config into DRF Object
type: object type: object

View file

@ -1,10 +1,11 @@
import { DefaultClient, PBResponse, QueryArguments } from "./Client"; import { DefaultClient, PBResponse, QueryArguments } from "./Client";
import { Provider } from "./Providers";
export class Application { export class Application {
pk: string; pk: string;
name: string; name: string;
slug: string; slug: string;
provider: number; provider: Provider;
launch_url: string; launch_url: string;
meta_launch_url: string; meta_launch_url: string;
@ -24,4 +25,8 @@ export class Application {
static list(filter?: QueryArguments): Promise<PBResponse<Application>> { static list(filter?: QueryArguments): Promise<PBResponse<Application>> {
return DefaultClient.fetch<PBResponse<Application>>(["core", "applications"], filter); return DefaultClient.fetch<PBResponse<Application>>(["core", "applications"], filter);
} }
static adminUrl(rest: string): string {
return `/administration/applications/${rest}`;
}
} }

View file

@ -4,6 +4,8 @@ export class Provider {
pk: number; pk: number;
name: string; name: string;
authorization_flow: string; authorization_flow: string;
verbose_name: string;
verbose_name_plural: string;
constructor() { constructor() {
throw Error(); throw Error();

View file

@ -136,6 +136,12 @@ select[multiple] {
--pf-c-table--BorderColor: var(--ak-dark-background-lighter); --pf-c-table--BorderColor: var(--ak-dark-background-lighter);
--pf-c-table--cell--Color: var(--ak-dark-foreground); --pf-c-table--cell--Color: var(--ak-dark-foreground);
} }
.pf-c-table__text {
color: var(--ak-dark-foreground) !important;
}
.pf-c-table__sort-indicator i {
color: var(--ak-dark-foreground) !important;
}
/* class for pagination text */ /* class for pagination text */
.pf-c-options-menu__toggle { .pf-c-options-menu__toggle {
color: var(--ak-dark-foreground); color: var(--ak-dark-foreground);

View file

@ -1,7 +1,7 @@
import { gettext } from "django"; import { gettext } from "django";
import { customElement, html, property, TemplateResult } from "lit-element"; import { customElement, html, property, TemplateResult } from "lit-element";
import { PBResponse } from "../../api/Client"; import { PBResponse } from "../../api/Client";
import { Table } from "../../elements/table/Table"; import { Table, TableColumn } from "../../elements/table/Table";
import { PolicyBinding } from "../../api/PolicyBindings"; import { PolicyBinding } from "../../api/PolicyBindings";
import "../../elements/Tabs"; import "../../elements/Tabs";
@ -22,8 +22,14 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
}); });
} }
columns(): string[] { columns(): TableColumn[] {
return ["Policy", "Enabled", "Order", "Timeout", ""]; return [
new TableColumn("Policy"),
new TableColumn("Enabled", "enabled"),
new TableColumn("Order", "order"),
new TableColumn("Timeout", "timeout"),
new TableColumn(""),
];
} }
row(item: PolicyBinding): TemplateResult[] { row(item: PolicyBinding): TemplateResult[] {

View file

@ -6,10 +6,72 @@ import { COMMON_STYLES } from "../../common/styles";
import "./TablePagination"; import "./TablePagination";
import "../EmptyState"; import "../EmptyState";
export class TableColumn {
title: string;
orderBy?: string;
onClick?: () => void;
constructor(title: string, orderBy?: string) {
this.title = title;
this.orderBy = orderBy;
}
headerClickHandler(table: Table<unknown>): void {
if (!this.orderBy) {
return;
}
if (table.order === this.orderBy) {
table.order = `-${this.orderBy}`;
} else {
table.order = this.orderBy;
}
table.fetch();
}
private getSortIndicator(table: Table<unknown>): string {
switch (table.order) {
case this.orderBy:
return "fa-long-arrow-alt-down";
case `-${this.orderBy}`:
return "fa-long-arrow-alt-up";
default:
return "fa-arrows-alt-v";
}
}
renderSortable(table: Table<unknown>): TemplateResult {
return html`
<button class="pf-c-table__button" @click=${() => this.headerClickHandler(table)}>
<div class="pf-c-table__button-content">
<span class="pf-c-table__text">${gettext(this.title)}</span>
<span class="pf-c-table__sort-indicator">
<i class="fas ${this.getSortIndicator(table)}"></i>
</span>
</div>
</button>`;
}
render(table: Table<unknown>): TemplateResult {
return html`<th
role="columnheader"
scope="col"
class="
${this.orderBy ? "pf-c-table__sort " : " "}
${(table.order === this.orderBy || table.order === `-${this.orderBy}`) ? "pf-m-selected " : ""}
">
${this.orderBy ? this.renderSortable(table) : html`${gettext(this.title)}`}
</th>`;
}
}
export abstract class Table<T> extends LitElement { export abstract class Table<T> extends LitElement {
abstract apiEndpoint(page: number): Promise<PBResponse<T>>; abstract apiEndpoint(page: number): Promise<PBResponse<T>>;
abstract columns(): Array<string>; abstract columns(): TableColumn[];
abstract row(item: T): Array<TemplateResult>; abstract row(item: T): TemplateResult[];
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
renderExpanded(item: T): TemplateResult { renderExpanded(item: T): TemplateResult {
@ -25,6 +87,12 @@ export abstract class Table<T> extends LitElement {
@property({type: Number}) @property({type: Number})
page = 1; page = 1;
@property({type: String})
order?: string;
@property({type: String})
search?: string;
@property({type: Boolean}) @property({type: Boolean})
expandable = false; expandable = false;
@ -43,6 +111,7 @@ export abstract class Table<T> extends LitElement {
} }
public fetch(): void { public fetch(): void {
this.data = undefined;
this.apiEndpoint(this.page).then((r) => { this.apiEndpoint(this.page).then((r) => {
this.data = r; this.data = r;
this.page = r.pagination.current; this.page = r.pagination.current;
@ -123,12 +192,17 @@ export abstract class Table<T> extends LitElement {
</button>`; </button>`;
} }
renderSearch(): TemplateResult {
return html``;
}
renderTable(): TemplateResult { renderTable(): TemplateResult {
if (!this.data) { if (!this.data) {
this.fetch(); this.fetch();
} }
return html`<div class="pf-c-toolbar"> return html`<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content"> <div class="pf-c-toolbar__content">
${this.renderSearch()}&nbsp;
<div class="pf-c-toolbar__bulk-select"> <div class="pf-c-toolbar__bulk-select">
${this.renderToolbar()} ${this.renderToolbar()}
</div> </div>
@ -143,7 +217,7 @@ export abstract class Table<T> extends LitElement {
<thead> <thead>
<tr role="row"> <tr role="row">
${this.expandable ? html`<td role="cell">` : html``} ${this.expandable ? html`<td role="cell">` : html``}
${this.columns().map((col) => html`<th role="columnheader" scope="col">${gettext(col)}</th>`)} ${this.columns().map((col) => col.render(this))}
</tr> </tr>
</thead> </thead>
${this.data ? this.renderRows() : this.renderLoading()} ${this.data ? this.renderRows() : this.renderLoading()}

View file

@ -1,19 +1,34 @@
import { html, TemplateResult } from "lit-html"; import { html, TemplateResult } from "lit-html";
import { ifDefined } from "lit-html/directives/if-defined";
import { Table } from "./Table"; import { Table } from "./Table";
import "./TableSearch";
export abstract class TablePage<T> extends Table<T> { export abstract class TablePage<T> extends Table<T> {
abstract pageTitle(): string; abstract pageTitle(): string;
abstract pageDescription(): string; abstract pageDescription(): string | undefined;
abstract pageIcon(): string; abstract pageIcon(): string;
abstract searchEnabled(): boolean;
renderSearch(): TemplateResult {
if (!this.searchEnabled()) {
return super.renderSearch();
}
return html`<ak-table-search value=${ifDefined(this.search)} .onSearch=${(value: string) => {
this.search = value;
this.fetch();
}}>
</ak-table-search>`;
}
render(): TemplateResult { render(): TemplateResult {
const description = this.pageDescription();
return html`<section class="pf-c-page__main-section pf-m-light"> return html`<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content"> <div class="pf-c-content">
<h1> <h1>
<i class="${this.pageIcon()}"></i> <i class="${this.pageIcon()}"></i>
${this.pageTitle()} ${this.pageTitle()}
</h1> </h1>
<p>${this.pageDescription()}</p> ${description ? html`<p>${description}</p>` : html``}
</div> </div>
</section> </section>
<section class="pf-c-page__main-section pf-m-no-padding-mobile"> <section class="pf-c-page__main-section pf-m-no-padding-mobile">

View file

@ -0,0 +1,41 @@
import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element";
import { ifDefined } from "lit-html/directives/if-defined";
import { COMMON_STYLES } from "../../common/styles";
@customElement("ak-table-search")
export class TableSearch extends LitElement {
@property()
value?: string;
@property()
onSearch?: (value: string) => void;
static get styles(): CSSResult[] {
return COMMON_STYLES;
}
render(): TemplateResult {
return html`<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<form class="pf-c-input-group" method="GET" @submit=${(e: Event) => {
e.preventDefault();
if (!this.onSearch) return;
const el = this.shadowRoot?.querySelector<HTMLInputElement>("input[type=search]");
if (!el) return;
if (el.value === "") return;
this.onSearch(el?.value);
}}>
<input class="pf-c-form-control" name="search" type="search" placeholder="Search..." value="${ifDefined(this.value)}" @search=${() => {
if (!this.onSearch) return;
this.onSearch("");
}}>
<button class="pf-c-button pf-m-control" type="submit">
<i class="fas fa-search" aria-hidden="true"></i>
</button>
</form>
</div>
</div>`;
}
}

View file

@ -14,7 +14,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [
return User.me().then(u => u.is_superuser); return User.me().then(u => u.is_superuser);
}), }),
new SidebarItem("Administration").children( new SidebarItem("Administration").children(
new SidebarItem("Applications", "/administration/applications/").activeWhen( new SidebarItem("Applications", "/applications/").activeWhen(
`^/applications/(?<slug>${SLUG_REGEX})/$` `^/applications/(?<slug>${SLUG_REGEX})/$`
), ),
new SidebarItem("Sources", "/administration/sources/").activeWhen( new SidebarItem("Sources", "/administration/sources/").activeWhen(

View file

@ -1,14 +1,18 @@
import { gettext } from "django"; import { gettext } from "django";
import { customElement, html, TemplateResult } from "lit-element"; import { customElement, html, property, TemplateResult } from "lit-element";
import { Application } from "../../api/Applications"; import { Application } from "../../api/Applications";
import { PBResponse } from "../../api/Client"; import { PBResponse } from "../../api/Client";
import { TablePage } from "../../elements/table/TablePage"; import { TablePage } from "../../elements/table/TablePage";
import "../../elements/buttons/ModalButton"; import "../../elements/buttons/ModalButton";
import "../../elements/buttons/SpinnerButton"; import "../../elements/buttons/SpinnerButton";
import { TableColumn } from "../../elements/table/Table";
@customElement("ak-application-list") @customElement("ak-application-list")
export class ApplicationList extends TablePage<Application> { export class ApplicationList extends TablePage<Application> {
searchEnabled(): boolean {
return true;
}
pageTitle(): string { pageTitle(): string {
return gettext("Applications"); return gettext("Applications");
} }
@ -19,31 +23,51 @@ export class ApplicationList extends TablePage<Application> {
return gettext("pf-icon pf-icon-applications"); return gettext("pf-icon pf-icon-applications");
} }
@property()
order = "name";
apiEndpoint(page: number): Promise<PBResponse<Application>> { apiEndpoint(page: number): Promise<PBResponse<Application>> {
return Application.list({ return Application.list({
ordering: "order", ordering: this.order,
page: page, page: page,
search: this.search || "",
}); });
} }
columns(): string[] { columns(): TableColumn[] {
return ["Name", "Slug", "Provider", "Provider Type", ""]; return [
new TableColumn(""),
new TableColumn("Name", "name"),
new TableColumn("Slug", "slug"),
new TableColumn("Provider"),
new TableColumn("Provider Type"),
new TableColumn(""),
];
} }
row(item: Application): TemplateResult[] { row(item: Application): TemplateResult[] {
return [ return [
html`${item.name}`,
html`${item.slug}`,
html`${item.provider}`,
html`${item.provider}`,
html` html`
<ak-modal-button href="administration/policies/bindings/${item.pk}/update/"> ${item.meta_icon ?
html`<img class="app-icon pf-c-avatar" src="${item.meta_icon}" alt="${gettext("Application Icon")}">` :
html`<i class="pf-icon pf-icon-arrow"></i>`}`,
html`<a href="#/applications/${item.slug}/">
<div>
${item.name}
</div>
${item.meta_publisher ? html`<small>${item.meta_publisher}</small>` : html``}
</a>`,
html`<code>${item.slug}</code>`,
html`${item.provider.name}`,
html`${item.provider.verbose_name}`,
html`
<ak-modal-button href="${Application.adminUrl(`${item.pk}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary"> <ak-spinner-button slot="trigger" class="pf-m-secondary">
Edit Edit
</ak-spinner-button> </ak-spinner-button>
<div slot="modal"></div> <div slot="modal"></div>
</ak-modal-button> </ak-modal-button>
<ak-modal-button href="administration/policies/bindings/${item.pk}/delete/"> <ak-modal-button href="${Application.adminUrl(`${item.pk}/delete/`)}">
<ak-spinner-button slot="trigger" class="pf-m-danger"> <ak-spinner-button slot="trigger" class="pf-m-danger">
Delete Delete
</ak-spinner-button> </ak-spinner-button>
@ -52,4 +76,16 @@ export class ApplicationList extends TablePage<Application> {
`, `,
]; ];
} }
renderToolbar(): TemplateResult {
return html`
<ak-modal-button href=${Application.adminUrl("create/")}>
<ak-spinner-button slot="trigger" class="pf-m-primary">
${gettext("Create")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
${super.renderToolbar()}
`;
}
} }

View file

@ -1,7 +1,7 @@
import { gettext } from "django"; import { gettext } from "django";
import { customElement, html, property, TemplateResult } from "lit-element"; import { customElement, html, property, TemplateResult } from "lit-element";
import { PBResponse } from "../../api/Client"; import { PBResponse } from "../../api/Client";
import { Table } from "../../elements/table/Table"; import { Table, TableColumn } from "../../elements/table/Table";
import "../../elements/Tabs"; import "../../elements/Tabs";
import "../../elements/AdminLoginsChart"; import "../../elements/AdminLoginsChart";
@ -25,8 +25,13 @@ export class BoundStagesList extends Table<FlowStageBinding> {
}); });
} }
columns(): string[] { columns(): TableColumn[] {
return ["Order", "Name", "Type", ""]; return [
new TableColumn("Order"),
new TableColumn("Name"),
new TableColumn("Type"),
new TableColumn(""),
];
} }
row(item: FlowStageBinding): TemplateResult[] { row(item: FlowStageBinding): TemplateResult[] {