diff --git a/passbook/admin/templates/administration/base.html b/passbook/admin/templates/administration/base.html index fadc37d70..208108c0d 100644 --- a/passbook/admin/templates/administration/base.html +++ b/passbook/admin/templates/administration/base.html @@ -5,35 +5,45 @@ {% block nav_secondary %} {% endblock %} diff --git a/passbook/admin/templates/administration/property_mapping/list.html b/passbook/admin/templates/administration/property_mapping/list.html new file mode 100644 index 000000000..7bf8c2da2 --- /dev/null +++ b/passbook/admin/templates/administration/property_mapping/list.html @@ -0,0 +1,52 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load utils %} + +{% block title %} +{% title %} +{% endblock %} + +{% block content %} +
+

{% trans "Property Mappings" %}

+ {% trans "Property Mappings allow you expose provider-specific attributes." %} +
+ +
+ + + + + + + + + + {% for property_mapping in object_list %} + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Type' %}
{{ property_mapping.name }} ({{ property_mapping.slug }}){{ property_mapping|verbose_name }} + {% trans 'Edit' %} + {% trans 'Delete' %} +
+
+{% endblock %} diff --git a/passbook/admin/templates/generic/form.html b/passbook/admin/templates/generic/form.html index 40a2b7568..4de06b338 100644 --- a/passbook/admin/templates/generic/form.html +++ b/passbook/admin/templates/generic/form.html @@ -3,6 +3,11 @@ {% load i18n %} {% load utils %} +{% block head %} +{{ block.super }} +{{ form.media.css }} +{% endblock %} + {% block content %}
{% block above_form %} @@ -16,3 +21,8 @@
{% endblock %} + +{% block scripts %} +{{ block.super }} +{{ form.media.js }} +{% endblock %} diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py index e2d3a6236..fa4c9ec0a 100644 --- a/passbook/admin/urls.py +++ b/passbook/admin/urls.py @@ -2,8 +2,8 @@ from django.urls import include, path from passbook.admin.views import (applications, audit, factors, groups, - invitations, overview, policy, providers, - sources, users) + invitations, overview, policy, + property_mapping, providers, sources, users) urlpatterns = [ path('', overview.AdministrationOverviewView.as_view(), name='overview'), @@ -43,6 +43,15 @@ urlpatterns = [ factors.FactorUpdateView.as_view(), name='factor-update'), path('factors//delete/', factors.FactorDeleteView.as_view(), name='factor-delete'), + # Factors + path('property-mappings/', property_mapping.PropertyMappingListView.as_view(), + name='property-mappings'), + path('property-mappings/create/', + property_mapping.PropertyMappingCreateView.as_view(), name='property-mapping-create'), + path('property-mappings//update/', + property_mapping.PropertyMappingUpdateView.as_view(), name='property-mapping-update'), + path('property-mappings//delete/', + property_mapping.PropertyMappingDeleteView.as_view(), name='property-mapping-delete'), # Invitations path('invitations/', invitations.InvitationListView.as_view(), name='invitations'), path('invitations/create/', diff --git a/passbook/admin/views/property_mapping.py b/passbook/admin/views/property_mapping.py new file mode 100644 index 000000000..c4cf7fad3 --- /dev/null +++ b/passbook/admin/views/property_mapping.py @@ -0,0 +1,90 @@ +"""passbook PropertyMapping administration""" +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.http import Http404 +from django.urls import reverse_lazy +from django.utils.translation import ugettext as _ +from django.views.generic import CreateView, DeleteView, ListView, UpdateView + +from passbook.admin.mixins import AdminRequiredMixin +from passbook.core.models import PropertyMapping +from passbook.lib.utils.reflection import path_to_class + + +def all_subclasses(cls): + """Recursively return all subclassess of cls""" + return set(cls.__subclasses__()).union( + [s for c in cls.__subclasses__() for s in all_subclasses(c)]) + + +class PropertyMappingListView(AdminRequiredMixin, ListView): + """Show list of all property_mappings""" + + model = PropertyMapping + template_name = 'administration/property_mapping/list.html' + ordering = 'name' + + def get_context_data(self, **kwargs): + kwargs['types'] = { + x.__name__: x._meta.verbose_name for x in all_subclasses(PropertyMapping)} + return super().get_context_data(**kwargs) + + def get_queryset(self): + return super().get_queryset().select_subclasses() + + +class PropertyMappingCreateView(SuccessMessageMixin, AdminRequiredMixin, CreateView): + """Create new PropertyMapping""" + + template_name = 'generic/create.html' + success_url = reverse_lazy('passbook_admin:property-mappings') + success_message = _('Successfully created Property Mapping') + + def get_context_data(self, **kwargs): + kwargs = super().get_context_data(**kwargs) + property_mapping_type = self.request.GET.get('type') + model = next(x for x in all_subclasses(PropertyMapping) + if x.__name__ == property_mapping_type) + kwargs['type'] = model._meta.verbose_name + return kwargs + + def get_form_class(self): + property_mapping_type = self.request.GET.get('type') + model = next(x for x in all_subclasses(PropertyMapping) + if x.__name__ == property_mapping_type) + if not model: + raise Http404 + return path_to_class(model.form) + + +class PropertyMappingUpdateView(SuccessMessageMixin, AdminRequiredMixin, UpdateView): + """Update property_mapping""" + + model = PropertyMapping + template_name = 'generic/update.html' + success_url = reverse_lazy('passbook_admin:property-mappings') + success_message = _('Successfully updated Property Mapping') + + def get_form_class(self): + form_class_path = self.get_object().form + form_class = path_to_class(form_class_path) + return form_class + + def get_object(self, queryset=None): + return PropertyMapping.objects.filter(pk=self.kwargs.get('pk')).select_subclasses().first() + + +class PropertyMappingDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView): + """Delete property_mapping""" + + model = PropertyMapping + template_name = 'generic/delete.html' + success_url = reverse_lazy('passbook_admin:property-mappings') + success_message = _('Successfully deleted Property Mapping') + + def get_object(self, queryset=None): + return PropertyMapping.objects.filter(pk=self.kwargs.get('pk')).select_subclasses().first() + + def delete(self, request, *args, **kwargs): + messages.success(self.request, self.success_message) + return super().delete(request, *args, **kwargs) diff --git a/passbook/core/auth/factors/password.py b/passbook/core/auth/factors/password.py index 136759517..d8956d377 100644 --- a/passbook/core/auth/factors/password.py +++ b/passbook/core/auth/factors/password.py @@ -37,7 +37,7 @@ class PasswordFactor(FormView, AuthenticationFactor): send_email.delay(self.pending_user.email, _('Forgotten password'), 'email/account_password_reset.html', { 'url': self.request.build_absolute_uri( - reverse('passbook_core:passbook_core:auth-password-reset', + reverse('passbook_core:auth-password-reset', kwargs={ 'nonce': nonce.uuid }) diff --git a/passbook/core/forms/factors.py b/passbook/core/forms/factors.py index 30a587546..11d0061c6 100644 --- a/passbook/core/forms/factors.py +++ b/passbook/core/forms/factors.py @@ -2,6 +2,7 @@ from django import forms from passbook.core.models import DummyFactor, PasswordFactor +from passbook.lib.fields import DynamicArrayField GENERAL_FIELDS = ['name', 'slug', 'order', 'policies', 'enabled'] @@ -16,6 +17,9 @@ class PasswordFactorForm(forms.ModelForm): 'name': forms.TextInput(), 'order': forms.NumberInput(), } + field_classes = { + 'backends': DynamicArrayField + } class DummyFactorForm(forms.ModelForm): """Form to create/edit Dummy Factor""" diff --git a/passbook/core/migrations/0017_propertymapping.py b/passbook/core/migrations/0017_propertymapping.py new file mode 100644 index 000000000..c53c910c1 --- /dev/null +++ b/passbook/core/migrations/0017_propertymapping.py @@ -0,0 +1,26 @@ +# Generated by Django 2.1.7 on 2019-03-08 10:40 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_core', '0016_auto_20190227_1355'), + ] + + operations = [ + migrations.CreateModel( + name='PropertyMapping', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField()), + ], + options={ + 'verbose_name': 'Property Mapping', + 'verbose_name_plural': 'Property Mappings', + }, + ), + ] diff --git a/passbook/core/migrations/0018_provider_property_mappings.py b/passbook/core/migrations/0018_provider_property_mappings.py new file mode 100644 index 000000000..a845d1c81 --- /dev/null +++ b/passbook/core/migrations/0018_provider_property_mappings.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-03-08 10:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_core', '0017_propertymapping'), + ] + + operations = [ + migrations.AddField( + model_name='provider', + name='property_mappings', + field=models.ManyToManyField(blank=True, default=None, to='passbook_core.PropertyMapping'), + ), + ] diff --git a/passbook/core/models.py b/passbook/core/models.py index 2f6d526d8..7772a6d0f 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -60,6 +60,8 @@ class User(AbstractUser): class Provider(models.Model): """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" + property_mappings = models.ManyToManyField('PropertyMapping', default=None, blank=True) + objects = InheritanceManager() # This class defines no field for easier inheritance @@ -412,7 +414,7 @@ class Invitation(UUIDModel): verbose_name_plural = _('Invitations') class Nonce(UUIDModel): - """One-time link for password resets/signup-confirmations""" + """One-time link for password resets/sign-up-confirmations""" expires = models.DateTimeField(default=default_nonce_duration) user = models.ForeignKey('User', on_delete=models.CASCADE) @@ -424,3 +426,19 @@ class Nonce(UUIDModel): verbose_name = _('Nonce') verbose_name_plural = _('Nonces') + +class PropertyMapping(UUIDModel): + """User-defined key -> x mapping which can be used by providers to expose extra data.""" + + name = models.TextField() + + form = '' + objects = InheritanceManager() + + def __str__(self): + return "Property Mapping %s" % self.name + + class Meta: + + verbose_name = _('Property Mapping') + verbose_name_plural = _('Property Mappings') diff --git a/passbook/core/static/css/passbook.css b/passbook/core/static/css/passbook.css new file mode 100644 index 000000000..86711d8b1 --- /dev/null +++ b/passbook/core/static/css/passbook.css @@ -0,0 +1,23 @@ +.dynamic-array-widget .array-item { + display: flex; + align-items: center; + margin-bottom: 15px; +} + +.dynamic-array-widget .remove_sign { + width: 10px; + height: 2px; + background: #a41515; + border-radius: 1px; +} + +.dynamic-array-widget .remove { + height: 15px; + display: flex; + align-items: center; + margin-left: 5px; +} + +.dynamic-array-widget .remove:hover { + cursor: pointer; +} diff --git a/passbook/core/static/js/passbook.js b/passbook/core/static/js/passbook.js index a52cfe21e..cb89bbd27 100644 --- a/passbook/core/static/js/passbook.js +++ b/passbook/core/static/js/passbook.js @@ -16,3 +16,33 @@ const typeHandler = function (e) { $source.on('input', typeHandler) // register for oninput $source.on('propertychange', typeHandler) // for IE8 + +window.addEventListener('load', function () { + + function addRemoveEventListener(widgetElement) { + widgetElement.querySelectorAll('.array-remove').forEach(function (element) { + element.addEventListener('click', function () { + this.parentNode.parentNode.remove(); + }); + }); + } + + document.querySelectorAll('.dynamic-array-widget').forEach(function (widgetElement) { + + addRemoveEventListener(widgetElement); + + widgetElement.querySelector('.add-array-item').addEventListener('click', function () { + var first = widgetElement.querySelector('.array-item'); + var newElement = first.cloneNode(true); + var id_parts = newElement.querySelector('input').getAttribute('id').split('_'); + var id = id_parts.slice(0, -1).join('_') + '_' + String(parseInt(id_parts.slice(-1)[0]) + 1); + newElement.querySelector('input').setAttribute('id', id); + newElement.querySelector('input').value = ''; + + addRemoveEventListener(newElement); + first.parentElement.insertBefore(newElement, first.parentNode.lastChild); + }); + + }); + +}); diff --git a/passbook/core/templates/base/skeleton.html b/passbook/core/templates/base/skeleton.html index 69a60d503..dad228996 100644 --- a/passbook/core/templates/base/skeleton.html +++ b/passbook/core/templates/base/skeleton.html @@ -16,6 +16,7 @@ +