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:
parent
eeeb14a045
commit
beabba2890
|
@ -5,4 +5,4 @@ load-plugins=pylint_django,pylint.extensions.bad_builtin
|
||||||
extension-pkg-whitelist=lxml
|
extension-pkg-whitelist=lxml
|
||||||
const-rgx=[a-zA-Z0-9_]{1,40}$
|
const-rgx=[a-zA-Z0-9_]{1,40}$
|
||||||
ignored-modules=django-otp
|
ignored-modules=django-otp
|
||||||
jobs=4
|
jobs=12
|
||||||
|
|
|
@ -10,14 +10,14 @@ from passbook.api.permissions import CustomObjectPermissions
|
||||||
from passbook.audit.api import EventViewSet
|
from passbook.audit.api import EventViewSet
|
||||||
from passbook.core.api.applications import ApplicationViewSet
|
from passbook.core.api.applications import ApplicationViewSet
|
||||||
from passbook.core.api.groups import GroupViewSet
|
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.propertymappings import PropertyMappingViewSet
|
||||||
from passbook.core.api.providers import ProviderViewSet
|
from passbook.core.api.providers import ProviderViewSet
|
||||||
from passbook.core.api.sources import SourceViewSet
|
from passbook.core.api.sources import SourceViewSet
|
||||||
from passbook.core.api.users import UserViewSet
|
from passbook.core.api.users import UserViewSet
|
||||||
from passbook.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet
|
from passbook.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet
|
||||||
from passbook.lib.utils.reflection import get_apps
|
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.dummy.api import DummyPolicyViewSet
|
||||||
from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet
|
from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet
|
||||||
from passbook.policies.expression.api import ExpressionPolicyViewSet
|
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/applications", ApplicationViewSet)
|
||||||
router.register("core/groups", GroupViewSet)
|
router.register("core/groups", GroupViewSet)
|
||||||
router.register("core/users", UserViewSet)
|
router.register("core/users", UserViewSet)
|
||||||
|
router.register("core/messages", MessagesViewSet, basename="messages")
|
||||||
|
|
||||||
router.register("audit/events", EventViewSet)
|
router.register("audit/events", EventViewSet)
|
||||||
|
|
||||||
|
|
36
passbook/core/api/messages.py
Normal file
36
passbook/core/api/messages.py
Normal 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)
|
|
@ -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()
|
|
|
@ -1,96 +1,22 @@
|
||||||
{% extends 'base/skeleton.html' %}
|
|
||||||
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load i18n %}
|
{% 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' %}
|
{% include 'partials/messages.html' %}
|
||||||
<div class="pf-c-login">
|
|
||||||
<div class="pf-c-login__container">
|
<header class="pf-c-login__main-header">
|
||||||
<header class="pf-c-login__header">
|
<h1 class="pf-c-title pf-m-3xl">
|
||||||
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
|
{% block card_title %}
|
||||||
alt="passbook icon" />
|
{% trans title %}
|
||||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
|
{% endblock %}
|
||||||
alt="passbook branding" />
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<main class="pf-c-login__main">
|
<div class="pf-c-login__main-body">
|
||||||
<header class="pf-c-login__main-header">
|
{% block card %}
|
||||||
<h1 class="pf-c-title pf-m-3xl">
|
<form method="POST" class="pf-c-form">
|
||||||
{% block card_title %}
|
{% include 'partials/form.html' %}
|
||||||
{% trans title %}
|
<div class="pf-c-form__group pf-m-action">
|
||||||
{% endblock %}
|
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">Log in</button>
|
||||||
</h1>
|
</div>
|
||||||
</header>
|
</form>
|
||||||
<div class="pf-c-login__main-body">
|
{% endblock %}
|
||||||
{% block card %}
|
|
||||||
<form method="POST" class="pf-c-form">
|
|
||||||
{% include 'partials/form.html' %}
|
|
||||||
<div class="pf-c-form__group pf-m-action">
|
|
||||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">Log in</button>
|
|
||||||
</div>
|
|
||||||
</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>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
|
|
@ -3,29 +3,6 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load passbook_utils %}
|
{% 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 %}
|
{% block above_form %}
|
||||||
<div class="pf-c-form__group">
|
<div class="pf-c-form__group">
|
||||||
<label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}">
|
<label class="pf-c-form__label" for="{{ field.name }}-{{ forloop.counter0 }}">
|
||||||
|
|
|
@ -5,7 +5,7 @@ from random import SystemRandom
|
||||||
from django.test import RequestFactory, TestCase
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.core.views.utils import LoadingView, PermissionDeniedView
|
from passbook.core.views.utils import PermissionDeniedView
|
||||||
|
|
||||||
|
|
||||||
class TestUtilViews(TestCase):
|
class TestUtilViews(TestCase):
|
||||||
|
@ -22,13 +22,6 @@ class TestUtilViews(TestCase):
|
||||||
)
|
)
|
||||||
self.factory = RequestFactory()
|
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):
|
def test_permission_denied_view(self):
|
||||||
"""Test PermissionDeniedView"""
|
"""Test PermissionDeniedView"""
|
||||||
request = self.factory.get("something")
|
request = self.factory.get("something")
|
||||||
|
|
|
@ -3,23 +3,6 @@ from django.utils.translation import ugettext as _
|
||||||
from django.views.generic import TemplateView
|
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):
|
class PermissionDeniedView(TemplateView):
|
||||||
"""Generic Permission denied view"""
|
"""Generic Permission denied view"""
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,6 @@ class FlowForm(forms.ModelForm):
|
||||||
"slug",
|
"slug",
|
||||||
"designation",
|
"designation",
|
||||||
"stages",
|
"stages",
|
||||||
"policies",
|
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
"name": _("Shown as the Title in Flow pages."),
|
"name": _("Shown as the Title in Flow pages."),
|
||||||
|
@ -33,7 +32,6 @@ class FlowForm(forms.ModelForm):
|
||||||
widgets = {
|
widgets = {
|
||||||
"name": forms.TextInput(),
|
"name": forms.TextInput(),
|
||||||
"stages": FilteredSelectMultiple(_("stages"), False),
|
"stages": FilteredSelectMultiple(_("stages"), False),
|
||||||
"policies": FilteredSelectMultiple(_("policies"), False),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -48,9 +46,7 @@ class FlowStageBindingForm(forms.ModelForm):
|
||||||
"stage",
|
"stage",
|
||||||
"re_evaluate_policies",
|
"re_evaluate_policies",
|
||||||
"order",
|
"order",
|
||||||
"policies",
|
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"name": forms.TextInput(),
|
"name": forms.TextInput(),
|
||||||
"policies": FilteredSelectMultiple(_("policies"), False),
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
|
@ -24,4 +25,5 @@ class StageView(TemplateView):
|
||||||
kwargs["title"] = self.executor.flow.name
|
kwargs["title"] = self.executor.flow.name
|
||||||
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
|
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
|
||||||
kwargs["user"] = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
kwargs["user"] = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||||
|
kwargs["primary_action"] = _("Continue")
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
169
passbook/flows/templates/flows/shell.html
Normal file
169
passbook/flows/templates/flows/shell.html
Normal 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 %}
|
|
@ -18,7 +18,7 @@ class TestHelperView(TestCase):
|
||||||
flow = Flow.objects.filter(designation=FlowDesignation.INVALIDATION,).first()
|
flow = Flow.objects.filter(designation=FlowDesignation.INVALIDATION,).first()
|
||||||
response = self.client.get(reverse("passbook_flows:default-invalidation"),)
|
response = self.client.get(reverse("passbook_flows:default-invalidation"),)
|
||||||
expected_url = reverse(
|
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.status_code, 302)
|
||||||
self.assertEqual(response.url, expected_url)
|
self.assertEqual(response.url, expected_url)
|
||||||
|
@ -33,7 +33,7 @@ class TestHelperView(TestCase):
|
||||||
|
|
||||||
response = self.client.get(reverse("passbook_flows:default-invalidation"),)
|
response = self.client.get(reverse("passbook_flows:default-invalidation"),)
|
||||||
expected_url = reverse(
|
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.status_code, 302)
|
||||||
self.assertEqual(response.url, expected_url)
|
self.assertEqual(response.url, expected_url)
|
||||||
|
|
|
@ -3,6 +3,7 @@ from django.urls import path
|
||||||
|
|
||||||
from passbook.flows.models import FlowDesignation
|
from passbook.flows.models import FlowDesignation
|
||||||
from passbook.flows.views import (
|
from passbook.flows.views import (
|
||||||
|
FlowExecutorShellView,
|
||||||
FlowExecutorView,
|
FlowExecutorView,
|
||||||
FlowPermissionDeniedView,
|
FlowPermissionDeniedView,
|
||||||
ToDefaultFlow,
|
ToDefaultFlow,
|
||||||
|
@ -40,5 +41,8 @@ urlpatterns = [
|
||||||
ToDefaultFlow.as_view(designation=FlowDesignation.PASSWORD_CHANGE),
|
ToDefaultFlow.as_view(designation=FlowDesignation.PASSWORD_CHANGE),
|
||||||
name="default-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"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
"""passbook multi-stage authentication engine"""
|
"""passbook multi-stage authentication engine"""
|
||||||
from typing import Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||||
from django.views.generic import View
|
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 structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.views.utils import PermissionDeniedView
|
from passbook.core.views.utils import PermissionDeniedView
|
||||||
|
@ -20,6 +22,7 @@ NEXT_ARG_NAME = "next"
|
||||||
SESSION_KEY_PLAN = "passbook_flows_plan"
|
SESSION_KEY_PLAN = "passbook_flows_plan"
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
||||||
class FlowExecutorView(View):
|
class FlowExecutorView(View):
|
||||||
"""Stage 1 Flow executor, passing requests to Stage Views"""
|
"""Stage 1 Flow executor, passing requests to Stage Views"""
|
||||||
|
|
||||||
|
@ -172,5 +175,17 @@ class ToDefaultFlow(View):
|
||||||
)
|
)
|
||||||
del self.request.session[SESSION_KEY_PLAN]
|
del self.request.session[SESSION_KEY_PLAN]
|
||||||
return redirect_with_qs(
|
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
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
"""policy API Views"""
|
"""policy API Views"""
|
||||||
from rest_framework.serializers import ModelSerializer
|
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||||
from rest_framework.viewsets import ModelViewSet
|
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):
|
class PolicyBindingSerializer(ModelSerializer):
|
||||||
|
@ -19,3 +20,28 @@ class PolicyBindingViewSet(ModelViewSet):
|
||||||
|
|
||||||
queryset = PolicyBinding.objects.all()
|
queryset = PolicyBinding.objects.all()
|
||||||
serializer_class = PolicyBindingSerializer
|
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()
|
||||||
|
|
|
@ -9,13 +9,8 @@ oauth_urlpatterns = [
|
||||||
# Custom OAuth 2 Authorize View
|
# Custom OAuth 2 Authorize View
|
||||||
path(
|
path(
|
||||||
"authorize/",
|
"authorize/",
|
||||||
oauth2.PassbookAuthorizationLoadingView.as_view(),
|
|
||||||
name="oauth2-authorize",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"authorize/permission_ok/",
|
|
||||||
oauth2.PassbookAuthorizationView.as_view(),
|
oauth2.PassbookAuthorizationView.as_view(),
|
||||||
name="oauth2-ok-authorize",
|
name="oauth2-authorize",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"authorize/permission_denied/",
|
"authorize/permission_denied/",
|
||||||
|
|
|
@ -3,35 +3,21 @@ from typing import Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
||||||
from django.forms import Form
|
from django.forms import Form
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
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 oauth2_provider.views.base import AuthorizationView
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event, EventAction
|
||||||
from passbook.core.models import Application
|
from passbook.core.models import Application
|
||||||
from passbook.core.views.access import AccessMixin
|
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
|
from passbook.providers.oauth.models import OAuth2Provider
|
||||||
|
|
||||||
LOGGER = get_logger()
|
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):
|
class OAuthPermissionDenied(PermissionDeniedView):
|
||||||
"""Show permission denied view"""
|
"""Show permission denied view"""
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ LOGGER = get_logger()
|
||||||
class BaseOAuthClient:
|
class BaseOAuthClient:
|
||||||
"""Base OAuth Client"""
|
"""Base OAuth Client"""
|
||||||
|
|
||||||
session: Session = None
|
session: Session
|
||||||
|
|
||||||
def __init__(self, source, token=""): # nosec
|
def __init__(self, source, token=""): # nosec
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -85,27 +85,29 @@ class TestIdentificationStage(TestCase):
|
||||||
slug="unique-enrollment-string",
|
slug="unique-enrollment-string",
|
||||||
designation=FlowDesignation.ENROLLMENT,
|
designation=FlowDesignation.ENROLLMENT,
|
||||||
)
|
)
|
||||||
|
FlowStageBinding.objects.create(
|
||||||
|
flow=flow, stage=self.stage, order=0,
|
||||||
|
)
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse(
|
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
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):
|
def test_recovery_flow(self):
|
||||||
"""Test that recovery flow is linked correctly"""
|
"""Test that recovery flow is linked correctly"""
|
||||||
flow = Flow.objects.create(
|
flow = Flow.objects.create(
|
||||||
name="enroll-test",
|
name="recovery-test",
|
||||||
slug="unique-recovery-string",
|
slug="unique-recovery-string",
|
||||||
designation=FlowDesignation.RECOVERY,
|
designation=FlowDesignation.RECOVERY,
|
||||||
)
|
)
|
||||||
|
FlowStageBinding.objects.create(
|
||||||
|
flow=flow, stage=self.stage, order=0,
|
||||||
|
)
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse(
|
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(flow.slug, response.rendered_content)
|
self.assertIn(flow.name, response.rendered_content)
|
||||||
|
|
|
@ -1,9 +1,39 @@
|
||||||
{% extends 'login/form_with_user.html' %}
|
|
||||||
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load passbook_utils %}
|
||||||
|
|
||||||
{% block beneath_form %}
|
<header class="pf-c-login__main-header">
|
||||||
{% if recovery_flow %}
|
<h1 class="pf-c-title pf-m-3xl">
|
||||||
<a href="{% url 'passbook_flows:flow-executor' flow_slug=recovery_flow.slug %}">{% trans 'Forgot password?' %}</a>
|
{% block card_title %}
|
||||||
{% endif %}
|
{% trans title %}
|
||||||
{% endblock %}
|
{% 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' %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
|
|
@ -311,3 +311,22 @@ input[data-is-monospace] {
|
||||||
visibility: visible
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -56,17 +56,19 @@ document.querySelectorAll("input[name=name]").forEach((input) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hamburger Menu
|
// 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) => {
|
||||||
const sidebar = document.querySelector(".pf-c-page__sidebar");
|
toggle.addEventListener("click", (e) => {
|
||||||
if (sidebar.classList.contains("pf-m-expanded")) {
|
const sidebar = document.querySelector(".pf-c-page__sidebar");
|
||||||
// Sidebar already expanded
|
if (sidebar.classList.contains("pf-m-expanded")) {
|
||||||
sidebar.classList.remove("pf-m-expanded");
|
// Sidebar already expanded
|
||||||
sidebar.style.zIndex = 0;
|
sidebar.classList.remove("pf-m-expanded");
|
||||||
} else {
|
sidebar.style.zIndex = 0;
|
||||||
// Sidebar not expanded yet
|
} else {
|
||||||
sidebar.classList.add("pf-m-expanded");
|
// Sidebar not expanded yet
|
||||||
sidebar.style.zIndex = 200;
|
sidebar.classList.add("pf-m-expanded");
|
||||||
}
|
sidebar.style.zIndex = 200;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collapsable Menus in Sidebar
|
// Collapsable Menus in Sidebar
|
||||||
|
|
38
swagger.yaml
38
swagger.yaml
|
@ -341,6 +341,21 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
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/:
|
/core/users/:
|
||||||
get:
|
get:
|
||||||
operationId: core_users_list
|
operationId: core_users_list
|
||||||
|
@ -4917,6 +4932,29 @@ definitions:
|
||||||
attributes:
|
attributes:
|
||||||
title: Attributes
|
title: Attributes
|
||||||
type: object
|
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:
|
User:
|
||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
|
|
Reference in a new issue