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." %}
+
+
+
+
+
+
+
+
+
+ {% trans 'Name' %} |
+ {% trans 'Type' %} |
+ |
+
+
+
+ {% for property_mapping in object_list %}
+
+ {{ property_mapping.name }} ({{ property_mapping.slug }}) |
+ {{ property_mapping|verbose_name }} |
+
+ {% trans 'Edit' %}
+ {% trans 'Delete' %}
+ |
+
+ {% endfor %}
+
+
+
+{% 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 @@
+