flows: Load Stages without refreshing the whole page (#33)

* flows: initial implementation of FlowExecutorShell

* flows: load messages dynamically upon card refresh
This commit is contained in:
Jens L 2020-05-24 00:57:25 +02:00 committed by GitHub
parent eeeb14a045
commit beabba2890
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 428 additions and 237 deletions

View file

@ -5,4 +5,4 @@ load-plugins=pylint_django,pylint.extensions.bad_builtin
extension-pkg-whitelist=lxml
const-rgx=[a-zA-Z0-9_]{1,40}$
ignored-modules=django-otp
jobs=4
jobs=12

View file

@ -10,14 +10,14 @@ from passbook.api.permissions import CustomObjectPermissions
from passbook.audit.api import EventViewSet
from passbook.core.api.applications import ApplicationViewSet
from passbook.core.api.groups import GroupViewSet
from passbook.core.api.policies import PolicyViewSet
from passbook.core.api.messages import MessagesViewSet
from passbook.core.api.propertymappings import PropertyMappingViewSet
from passbook.core.api.providers import ProviderViewSet
from passbook.core.api.sources import SourceViewSet
from passbook.core.api.users import UserViewSet
from passbook.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet
from passbook.lib.utils.reflection import get_apps
from passbook.policies.api import PolicyBindingViewSet
from passbook.policies.api import PolicyBindingViewSet, PolicyViewSet
from passbook.policies.dummy.api import DummyPolicyViewSet
from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet
from passbook.policies.expression.api import ExpressionPolicyViewSet
@ -55,6 +55,7 @@ for _passbook_app in get_apps():
router.register("core/applications", ApplicationViewSet)
router.register("core/groups", GroupViewSet)
router.register("core/users", UserViewSet)
router.register("core/messages", MessagesViewSet, basename="messages")
router.register("audit/events", EventViewSet)

View file

@ -0,0 +1,36 @@
"""core messages API"""
from django.contrib.messages import get_messages
from drf_yasg.utils import swagger_auto_schema
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ReadOnlyField, Serializer
from rest_framework.viewsets import ViewSet
class MessageSerializer(Serializer):
"""Serialize Django Message into DRF Object"""
message = ReadOnlyField()
level = ReadOnlyField()
tags = ReadOnlyField()
extra_tags = ReadOnlyField()
level_tag = ReadOnlyField()
def create(self, request: Request) -> Response:
raise NotImplementedError
def update(self, request: Request) -> Response:
raise NotImplementedError
class MessagesViewSet(ViewSet):
"""Read-only view set that returns the current session's messages"""
permission_classes = [AllowAny]
@swagger_auto_schema(responses={200: MessageSerializer(many=True)})
def list(self, request: Request) -> Response:
"""List current messages and pass into Serializer"""
all_messages = list(get_messages(request))
return Response(MessageSerializer(all_messages, many=True).data)

View file

@ -1,31 +0,0 @@
"""Policy API Views"""
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import ReadOnlyModelViewSet
from passbook.policies.forms import GENERAL_FIELDS
from passbook.policies.models import Policy
class PolicySerializer(ModelSerializer):
"""Policy Serializer"""
__type__ = SerializerMethodField(method_name="get_type")
def get_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object"""
return obj._meta.object_name.lower().replace("policy", "")
class Meta:
model = Policy
fields = ["pk"] + GENERAL_FIELDS + ["__type__"]
class PolicyViewSet(ReadOnlyModelViewSet):
"""Policy Viewset"""
queryset = Policy.objects.all()
serializer_class = PolicySerializer
def get_queryset(self):
return Policy.objects.select_subclasses()

View file

@ -1,32 +1,8 @@
{% extends 'base/skeleton.html' %}
{% load static %}
{% load i18n %}
{% block body %}
<div class="pf-c-background-image">
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
<filter id="image_overlay">
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
<feFuncA type="table" tableValues="0 1"></feFuncA>
</feComponentTransfer>
</filter>
</svg>
</div>
{% include 'partials/messages.html' %}
<div class="pf-c-login">
<div class="pf-c-login__container">
<header class="pf-c-login__header">
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
alt="passbook icon" />
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
alt="passbook branding" />
</header>
<main class="pf-c-login__main">
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
{% block card_title %}
@ -44,53 +20,3 @@
</form>
{% endblock %}
</div>
<footer class="pf-c-login__main-footer">
{% if config.login.subtext %}
<p>{{ config.login.subtext }}</p>
{% endif %}
<ul class="pf-c-login__main-footer-links">
{% for source in sources %}
<li class="pf-c-login__main-footer-links-item">
<a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link">
{% if source.icon_path %}
<img src="{% static source.icon_path %}" alt="{{ source.name }}">
{% elif source.icon_url %}
<img src="icon_url" alt="{{ source.name }}">
{% else %}
<i class="pf-icon pf-icon-arrow" title="{{ source.name }}"></i>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% if enroll_url or recovery_url %}
<div class="pf-c-login__main-footer-band">
{% if enroll_url %}
<p class="pf-c-login__main-footer-band-item">
{% trans 'Need an account?' %}
<a href="{{ enroll_url }}">{% trans 'Sign up.' %}</a>
</p>
{% endif %}
{% if recovery_url %}
<p class="pf-c-login__main-footer-band-item">
<a href="{{ recovery_url }}">
{% trans 'Forgot username or password?' %}
</a>
</p>
{% endif %}
</div>
{% endif %}
</footer>
</main>
<footer class="pf-c-login__footer">
<p></p>
<ul class="pf-c-list pf-m-inline">
<li>
<a href="https://beryju.github.io/passbook/">{% trans 'Documentation' %}</a>
</li>
<!-- todo: load config.passbook.footer.links -->
</ul>
</footer>
</div>
</div>
{% endblock %}

View file

@ -3,29 +3,6 @@
{% load i18n %}
{% load passbook_utils %}
{% block head %}
{{ block.super }}
<style>
.form-control-static {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-control-static .left {
display: flex;
align-items: center;
}
.form-control-static img {
margin-right: 5px;
}
.form-control-static a {
padding-top: 3px;
padding-bottom: 3px;
line-height: 32px;
}
</style>
{% endblock %}
{% block above_form %}
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}">

View file

@ -5,7 +5,7 @@ from random import SystemRandom
from django.test import RequestFactory, TestCase
from passbook.core.models import User
from passbook.core.views.utils import LoadingView, PermissionDeniedView
from passbook.core.views.utils import PermissionDeniedView
class TestUtilViews(TestCase):
@ -22,13 +22,6 @@ class TestUtilViews(TestCase):
)
self.factory = RequestFactory()
def test_loading_view(self):
"""Test loading view"""
request = self.factory.get("something")
response = LoadingView.as_view(target_url="somestring")(request)
response.render()
self.assertIn("somestring", response.rendered_content)
def test_permission_denied_view(self):
"""Test PermissionDeniedView"""
request = self.factory.get("something")

View file

@ -3,23 +3,6 @@ from django.utils.translation import ugettext as _
from django.views.generic import TemplateView
class LoadingView(TemplateView):
"""View showing a loading template, and forwarding to real view using html forwarding."""
template_name = "login/loading.html"
title = _("Loading")
target_url = None
def get_url(self):
"""Return URL template will redirect to"""
return self.target_url
def get_context_data(self, **kwargs):
kwargs["title"] = self.title
kwargs["target_url"] = self.get_url()
return super().get_context_data(**kwargs)
class PermissionDeniedView(TemplateView):
"""Generic Permission denied view"""

View file

@ -18,7 +18,6 @@ class FlowForm(forms.ModelForm):
"slug",
"designation",
"stages",
"policies",
]
help_texts = {
"name": _("Shown as the Title in Flow pages."),
@ -33,7 +32,6 @@ class FlowForm(forms.ModelForm):
widgets = {
"name": forms.TextInput(),
"stages": FilteredSelectMultiple(_("stages"), False),
"policies": FilteredSelectMultiple(_("policies"), False),
}
@ -48,9 +46,7 @@ class FlowStageBindingForm(forms.ModelForm):
"stage",
"re_evaluate_policies",
"order",
"policies",
]
widgets = {
"name": forms.TextInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
}

View file

@ -2,6 +2,7 @@
from typing import Any, Dict
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
@ -24,4 +25,5 @@ class StageView(TemplateView):
kwargs["title"] = self.executor.flow.name
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
kwargs["user"] = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
kwargs["primary_action"] = _("Continue")
return super().get_context_data(**kwargs)

View file

@ -0,0 +1,169 @@
{% extends 'base/skeleton.html' %}
{% load static %}
{% load i18n %}
{% block head %}
{{ block.super }}
<style>
.pb-loading,
.pf-c-login__main >iframe {
display: flex;
height: 100%;
width: 100%;
justify-content: center;
align-items: center;
}
.pb-hidden {
display: none
}
</style>
{% endblock %}
{% block body %}
<div class="pf-c-background-image">
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
<filter id="image_overlay">
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
<feFuncA type="table" tableValues="0 1"></feFuncA>
</feComponentTransfer>
</filter>
</svg>
</div>
<ul class="pf-c-alert-group pf-m-toast">
</ul>
<div class="pf-c-login">
<div class="pf-c-login__container">
<header class="pf-c-login__header">
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
alt="passbook icon" />
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
alt="passbook branding" />
</header>
<main class="pf-c-login__main" id="flow-body">
<div class="pf-c-login__main-body pb-loading">
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
</main>
<footer class="pf-c-login__footer">
<p></p>
<ul class="pf-c-list pf-m-inline">
<li>
<a href="https://beryju.github.io/passbook/">{% trans 'Documentation' %}</a>
</li>
<!-- todo: load config.passbook.footer.links -->
</ul>
</footer>
</div>
</div>
<script>
const flowBodyUrl = "{{ exec_url }}";
const messagesUrl = "{{ msg_url }}";
const flowBody = document.querySelector("#flow-body");
const spinner = document.querySelector(".pb-loading");
const updateMessages = () => {
let messageContainer = document.querySelector(".pf-c-alert-group");
fetch(messagesUrl).then(response => {
messageContainer.innerHTML = "";
response.json().then(data => {
data.forEach(msg => {
let icon = "";
switch (msg.level_tag) {
case 'error':
icon = 'fas fa-exclamation-circle'
break;
case 'warning':
icon = 'fas fa-exclamation-triangle'
break;
case 'success':
icon = 'fas fa-check-circle'
break;
case 'info':
icon = 'fas fa-info'
break;
default:
break;
}
if (msg.level_tag === "error") {
msg.extra_tags = "pf-m-danger";
}
let item = `<li class="pf-c-alert-group__item">
<div class="pf-c-alert pf-m-${msg.level_tag} ${msg.extra_tags}">
<div class="pf-c-alert__icon">
<i class="${icon}"></i>
</div>
<h4 class="pf-c-alert__title">
${msg.message}
</h4>
</div>
</li>`;
var template = document.createElement('template');
template.innerHTML = item;
messageContainer.appendChild(template.content.firstChild);
});
});
});
};
const updateCard = (response) => {
if (!response.ok) {
console.log("well");
}
if (response.redirected && !response.url.endsWith(flowBodyUrl)) {
window.location = response.url;
} else {
response.text().then(text => {
flowBody.innerHTML = text;
updateMessages();
loadFormCode();
setFormSubmitHandlers();
});
}
};
const showSpinner = () => {
flowBody.innerHTML = "";
flowBody.appendChild(spinner);
};
const loadFormCode = () => {
document.querySelectorAll("#flow-body script").forEach(script => {
let newScript = document.createElement("script");
newScript.src = script.src;
document.head.appendChild(newScript);
});
}
const setFormSubmitHandlers = () => {
document.querySelectorAll("#flow-body form").forEach(form => {
console.log(`Setting action for form ${form}`);
// debugger;
form.action = flowBodyUrl;
console.log(`Adding handler for form ${form}`);
form.addEventListener('submit', (e) => {
e.preventDefault();
let formData = new FormData(form);
fetch(flowBodyUrl, {
method: 'post',
body: formData,
}).then((response) => {
showSpinner();
if (!response.url.endsWith(flowBodyUrl)) {
window.location = response.url;
} else {
updateCard(response);
}
});
});
});
};
fetch(flowBodyUrl).then(updateCard);
</script>
{% endblock %}

View file

@ -18,7 +18,7 @@ class TestHelperView(TestCase):
flow = Flow.objects.filter(designation=FlowDesignation.INVALIDATION,).first()
response = self.client.get(reverse("passbook_flows:default-invalidation"),)
expected_url = reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"passbook_flows:flow-executor-shell", kwargs={"flow_slug": flow.slug}
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, expected_url)
@ -33,7 +33,7 @@ class TestHelperView(TestCase):
response = self.client.get(reverse("passbook_flows:default-invalidation"),)
expected_url = reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"passbook_flows:flow-executor-shell", kwargs={"flow_slug": flow.slug}
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, expected_url)

View file

@ -3,6 +3,7 @@ from django.urls import path
from passbook.flows.models import FlowDesignation
from passbook.flows.views import (
FlowExecutorShellView,
FlowExecutorView,
FlowPermissionDeniedView,
ToDefaultFlow,
@ -40,5 +41,8 @@ urlpatterns = [
ToDefaultFlow.as_view(designation=FlowDesignation.PASSWORD_CHANGE),
name="default-password-change",
),
path("<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
path("b/<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
path(
"<slug:flow_slug>/", FlowExecutorShellView.as_view(), name="flow-executor-shell"
),
]

View file

@ -1,9 +1,11 @@
"""passbook multi-stage authentication engine"""
from typing import Optional
from typing import Any, Dict, Optional
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views.generic import View
from django.shortcuts import get_object_or_404, redirect, reverse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import TemplateView, View
from structlog import get_logger
from passbook.core.views.utils import PermissionDeniedView
@ -20,6 +22,7 @@ NEXT_ARG_NAME = "next"
SESSION_KEY_PLAN = "passbook_flows_plan"
@method_decorator(xframe_options_sameorigin, name="dispatch")
class FlowExecutorView(View):
"""Stage 1 Flow executor, passing requests to Stage Views"""
@ -172,5 +175,17 @@ class ToDefaultFlow(View):
)
del self.request.session[SESSION_KEY_PLAN]
return redirect_with_qs(
"passbook_flows:flow-executor", request.GET, flow_slug=flow.slug
"passbook_flows:flow-executor-shell", request.GET, flow_slug=flow.slug
)
class FlowExecutorShellView(TemplateView):
"""Executor Shell view, loads a dummy card with a spinner
that loads the next stage in the background."""
template_name = "flows/shell.html"
def get_context_data(self, **kwargs) -> Dict[str, Any]:
kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs)
kwargs["msg_url"] = reverse("passbook_api:messages-list")
return kwargs

View file

@ -1,8 +1,9 @@
"""policy API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from passbook.policies.models import PolicyBinding
from passbook.policies.forms import GENERAL_FIELDS
from passbook.policies.models import Policy, PolicyBinding
class PolicyBindingSerializer(ModelSerializer):
@ -19,3 +20,28 @@ class PolicyBindingViewSet(ModelViewSet):
queryset = PolicyBinding.objects.all()
serializer_class = PolicyBindingSerializer
class PolicySerializer(ModelSerializer):
"""Policy Serializer"""
__type__ = SerializerMethodField(method_name="get_type")
def get_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object"""
return obj._meta.object_name.lower().replace("policy", "")
class Meta:
model = Policy
fields = ["pk"] + GENERAL_FIELDS + ["__type__"]
class PolicyViewSet(ReadOnlyModelViewSet):
"""Policy Viewset"""
queryset = Policy.objects.all()
serializer_class = PolicySerializer
def get_queryset(self):
return Policy.objects.select_subclasses()

View file

@ -9,13 +9,8 @@ oauth_urlpatterns = [
# Custom OAuth 2 Authorize View
path(
"authorize/",
oauth2.PassbookAuthorizationLoadingView.as_view(),
name="oauth2-authorize",
),
path(
"authorize/permission_ok/",
oauth2.PassbookAuthorizationView.as_view(),
name="oauth2-ok-authorize",
name="oauth2-authorize",
),
path(
"authorize/permission_denied/",

View file

@ -3,35 +3,21 @@ from typing import Optional
from urllib.parse import urlencode
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.forms import Form
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, reverse
from django.utils.translation import ugettext as _
from oauth2_provider.views.base import AuthorizationView
from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.core.models import Application
from passbook.core.views.access import AccessMixin
from passbook.core.views.utils import LoadingView, PermissionDeniedView
from passbook.core.views.utils import PermissionDeniedView
from passbook.providers.oauth.models import OAuth2Provider
LOGGER = get_logger()
class PassbookAuthorizationLoadingView(LoginRequiredMixin, LoadingView):
"""Show loading view for permission checks"""
title = _("Checking permissions...")
def get_url(self):
querystring = urlencode(self.request.GET)
return (
reverse("passbook_providers_oauth:oauth2-ok-authorize") + "?" + querystring
)
class OAuthPermissionDenied(PermissionDeniedView):
"""Show permission denied view"""

View file

@ -19,7 +19,7 @@ LOGGER = get_logger()
class BaseOAuthClient:
"""Base OAuth Client"""
session: Session = None
session: Session
def __init__(self, source, token=""): # nosec
self.source = source

View file

@ -1 +1,23 @@
{% extends 'login/form.html' %}
{% load i18n %}
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
{% block card_title %}
{% trans title %}
{% endblock %}
</h1>
</header>
<div class="pf-c-login__main-body">
<form method="POST" class="pf-c-form">
{% block above_form %}
{% endblock %}
{% include 'partials/form.html' %}
{% block beneath_form %}
{% endblock %}
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
</div>
</form>
</div>

View file

@ -85,27 +85,29 @@ class TestIdentificationStage(TestCase):
slug="unique-enrollment-string",
designation=FlowDesignation.ENROLLMENT,
)
FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0,
)
response = self.client.get(
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
self.assertIn(flow.slug, response.rendered_content)
self.assertIn(flow.name, response.rendered_content)
def test_recovery_flow(self):
"""Test that recovery flow is linked correctly"""
flow = Flow.objects.create(
name="enroll-test",
name="recovery-test",
slug="unique-recovery-string",
designation=FlowDesignation.RECOVERY,
)
FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0,
)
response = self.client.get(
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
self.assertIn(flow.slug, response.rendered_content)
self.assertIn(flow.name, response.rendered_content)

View file

@ -1,9 +1,39 @@
{% extends 'login/form_with_user.html' %}
{% load i18n %}
{% load passbook_utils %}
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
{% block card_title %}
{% trans title %}
{% endblock %}
</h1>
</header>
<div class="pf-c-login__main-body">
{% block card %}
<form method="POST" class="pf-c-form">
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}">
<span class="pf-c-form__label-text">{% trans "Username" %}</span>
</label>
<div class="form-control-static">
<div class="left">
<img class="pf-c-avatar" src="{% gravatar user.email %}" alt="">
{{ user.username }}
</div>
<div class="right">
<a href="{% url 'passbook_flows:default-authentication' %}">{% trans 'Not you?' %}</a>
</div>
</div>
</div>
{% include 'partials/form.html' %}
{% block beneath_form %}
{% if recovery_flow %}
<a href="{% url 'passbook_flows:flow-executor' flow_slug=recovery_flow.slug %}">{% trans 'Forgot password?' %}</a>
{% endif %}
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
</div>
</form>
{% endblock %}
</div>

View file

@ -311,3 +311,22 @@ input[data-is-monospace] {
visibility: visible
}
}
/* Form with user */
.form-control-static {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-control-static .left {
display: flex;
align-items: center;
}
.form-control-static img {
margin-right: 5px;
}
.form-control-static a {
padding-top: 3px;
padding-bottom: 3px;
line-height: 32px;
}

View file

@ -56,7 +56,8 @@ document.querySelectorAll("input[name=name]").forEach((input) => {
});
// Hamburger Menu
document.querySelector(".pf-c-page__header-brand-toggle>button").addEventListener("click", (e) => {
document.querySelectorAll(".pf-c-page__header-brand-toggle>button").forEach((toggle) => {
toggle.addEventListener("click", (e) => {
const sidebar = document.querySelector(".pf-c-page__sidebar");
if (sidebar.classList.contains("pf-m-expanded")) {
// Sidebar already expanded
@ -68,6 +69,7 @@ document.querySelector(".pf-c-page__header-brand-toggle>button").addEventListene
sidebar.style.zIndex = 200;
}
});
});
// Collapsable Menus in Sidebar
document.querySelectorAll(".pf-m-expandable>.pf-c-nav__link").forEach((menu) => {

View file

@ -341,6 +341,21 @@ paths:
required: true
type: string
format: uuid
/core/messages/:
get:
operationId: core_messages_list
description: List current messages and pass into Serializer
parameters: []
responses:
'200':
description: ''
schema:
type: array
items:
$ref: '#/definitions/Message'
tags:
- core
parameters: []
/core/users/:
get:
operationId: core_users_list
@ -4917,6 +4932,29 @@ definitions:
attributes:
title: Attributes
type: object
Message:
type: object
properties:
message:
title: Message
type: string
readOnly: true
level:
title: Level
type: string
readOnly: true
tags:
title: Tags
type: string
readOnly: true
extra_tags:
title: Extra tags
type: string
readOnly: true
level_tag:
title: Level tag
type: string
readOnly: true
User:
required:
- username