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 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

View File

@ -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)

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,40 +1,16 @@
{% 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">
<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"> <h1 class="pf-c-title pf-m-3xl">
{% block card_title %} {% block card_title %}
{% trans title %} {% trans title %}
{% endblock %} {% endblock %}
</h1> </h1>
</header> </header>
<div class="pf-c-login__main-body"> <div class="pf-c-login__main-body">
{% block card %} {% block card %}
<form method="POST" class="pf-c-form"> <form method="POST" class="pf-c-form">
{% include 'partials/form.html' %} {% include 'partials/form.html' %}
@ -43,54 +19,4 @@
</div> </div>
</form> </form>
{% endblock %} {% 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 %}

View File

@ -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 }}">

View File

@ -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")

View File

@ -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"""

View File

@ -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),
} }

View File

@ -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)

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() 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)

View File

@ -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"
),
] ]

View File

@ -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

View File

@ -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()

View File

@ -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/",

View File

@ -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"""

View File

@ -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

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", 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)

View File

@ -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>

View File

@ -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;
}

View File

@ -56,7 +56,8 @@ 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) => {
toggle.addEventListener("click", (e) => {
const sidebar = document.querySelector(".pf-c-page__sidebar"); const sidebar = document.querySelector(".pf-c-page__sidebar");
if (sidebar.classList.contains("pf-m-expanded")) { if (sidebar.classList.contains("pf-m-expanded")) {
// Sidebar already expanded // Sidebar already expanded
@ -67,6 +68,7 @@ document.querySelector(".pf-c-page__header-brand-toggle>button").addEventListene
sidebar.classList.add("pf-m-expanded"); sidebar.classList.add("pf-m-expanded");
sidebar.style.zIndex = 200; sidebar.style.zIndex = 200;
} }
});
}); });
// Collapsable Menus in Sidebar // Collapsable Menus in Sidebar

View File

@ -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