Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • forge/apps/forgeid
1 result
Show changes
Commits on Source (5)
Showing
with 1739 additions and 38 deletions
......@@ -110,6 +110,8 @@ test:
- docker pull $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA
- docker tag $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA intranet_dev
- docker-compose pull --ignore-pull-failures
- export COMPOSE_PARALLEL_LIMIT=1000
- docker network prune
- docker-compose -p "$CI_CONCURRENT_ID" up -d s3_dev ldap_dev ldap_sasl_dev db_dev
- sleep 10
- docker-compose -p "$CI_CONCURRENT_ID" up -d
......
......@@ -41,6 +41,7 @@ django-ldapdb = "*"
sphinx = "*"
sphinx-rtd-theme = "*"
sshpubkeys = "*"
algoliasearch-django = "*"
[requires]
python_version = "3.8"
......
{
"_meta": {
"hash": {
"sha256": "3227f0aa00c144ccd6cd621b9ef221c83855a4bf3e6b1973164dd3c2cf1b3f87"
"sha256": "66f9d54accfc5266dda519b44eb08a980a03ebcae7aed107e189c2c87a858be2"
},
"pipfile-spec": 6,
"requires": {
......@@ -28,6 +28,21 @@
],
"version": "==0.7.12"
},
"algoliasearch": {
"hashes": [
"sha256:48460675555e6effab946adc58b7483bb88a5a68ddc4bdebee09d93e9253318c",
"sha256:842fb3e2fb57dc0daa2e97bf0d2e8191498781540901da118e98c24048d8d812"
],
"version": "==1.20.0"
},
"algoliasearch-django": {
"hashes": [
"sha256:c25b49b7df3d410625f983bbf907b063796d57c6021e1643987900e72d07906d",
"sha256:c64dc2dab76ade6b05638a188e4a4af1ec952d5aa407aeab3ce6cbaed082d4c5"
],
"index": "pypi",
"version": "==1.7.1"
},
"asgiref": {
"hashes": [
"sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a",
......@@ -46,11 +61,11 @@
},
"attrs": {
"hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
"sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a",
"sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==19.3.0"
"version": "==20.1.0"
},
"babel": {
"hashes": [
......@@ -76,18 +91,18 @@
},
"boto3": {
"hashes": [
"sha256:1cfbadf41777dade69a3e5eaf1b71d15b4ae616fd94d16a894b692e14319f4a2",
"sha256:cc3636828f1677ff93e8b1130c90dfe800187964e33786711450e8653d3f245f"
"sha256:0d9cbeb5c8ca67650cc963c77e2e3b3ab5dffeeee16e03d61d740755f8fc7c44",
"sha256:df73edf3bd6f191870212e04ae9a8bc6245fd6749f464e9fb950392a8d15bd8c"
],
"index": "pypi",
"version": "==1.14.46"
"version": "==1.14.47"
},
"botocore": {
"hashes": [
"sha256:2f15a755b990db13a7a9e06a124c6ca5fa1c4470d76672363024d7f2a6c2566c",
"sha256:6b134681c938f00b28424abf4b46fa6034b516d8add3a3f524e2292db61aa070"
"sha256:42b320b449df22cdb1232913e4a066919d127feb8e58ad98898831e6255ccfe0",
"sha256:eca25f01c503c2b86b394497f875a0eb0d3fe367dbc032f3a02851ba7e827109"
],
"version": "==1.17.46"
"version": "==1.17.47"
},
"certifi": {
"hashes": [
......@@ -1258,11 +1273,11 @@
},
"attrs": {
"hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
"sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a",
"sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==19.3.0"
"version": "==20.1.0"
},
"black": {
"hashes": [
......
......@@ -11,9 +11,9 @@ class OIDCClientExtensionGroupInline(admin.TabularInline):
@admin.register(models.OIDCClientExtension)
class OIDCClientExtensionAdmin(admin.ModelAdmin):
list_display = ("client",)
list_display = ("client", "is_restricted", "is_legacy")
list_display_links = list_display
list_filter = ("is_restricted",)
list_filter = ("is_restricted", "is_legacy")
filter_horizontal = ("managers",)
autocomplete_fields = ("client",)
inlines = (OIDCClientExtensionGroupInline,)
......
# Generated by Django 3.1 on 2020-08-20 13:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("cri_auth", "0001_initial")]
operations = [
migrations.AddField(
model_name="oidcclientextension",
name="is_legacy",
field=models.BooleanField(default=False),
)
]
import itertools
from django.db import models
from django.contrib.auth import get_user_model
......@@ -22,7 +24,6 @@ class OIDCClientExtensionGroup(models.Model):
_roles = models.CharField(max_length=150, blank=True, verbose_name="roles")
class Meta:
# app_label = "oidc_provider"
pass
@property
......@@ -40,10 +41,7 @@ class OIDCClientExtension(models.Model):
managers = models.ManyToManyField(get_user_model(), blank=True)
groups = models.ManyToManyField(
CRIGroup,
through=OIDCClientExtensionGroup,
related_name="oidcclientextension",
blank=True,
CRIGroup, through=OIDCClientExtensionGroup, blank=True
)
is_restricted = models.BooleanField(
......@@ -54,18 +52,96 @@ class OIDCClientExtension(models.Model):
),
)
is_legacy = models.BooleanField(
default=False,
help_text=(
"If enabled, the claims returned by the epita scope will match the old "
"OIDC API. Do not use for new OIDC clients."
),
)
class Meta:
# app_label = "oidc_provider"
verbose_name = "OIDC client extension"
def __str__(self):
return self.client.name
def get_user_roles(self, user):
oceg_list = OIDCClientExtensionGroup.objects.filter(
oidc_client_extension=self, group__in=user.get_groups()
).distinct()
return set(itertools.chain.from_iterable([oceg.roles for oceg in oceg_list]))
def get_user_groups(self, user):
prefix = "computed_parents__oidcclientextensiongroup"
return (
user.get_groups()
.filter(
models.Q(private=False)
| models.Q(oidcclientextensiongroup__oidc_client_extension=self)
| models.Q(
**{
f"{prefix}__oidc_client_extension": self,
f"{prefix}__client_can_see_private_subgroups": True,
}
)
)
.distinct()
)
def is_user_allowed(self, user):
if not self.is_restricted:
return True
prefix = "memberships__group__oidcclientextensiongroup"
return (
get_user_model()
.objects.filter(
models.Q(oidcclientextension__managers=user)
| models.Q(
**{
f"{prefix}__oidc_client_extension": self,
f"{prefix}__group_is_allowed": True,
}
)
| models.Q(
memberships__group__oidcclientextension=self,
memberships__group__managers=user,
)
| models.Q(
memberships__group__oidcclientextension=self,
memberships__group__computed_parents__managers=user,
),
pk=user.pk,
)
.exists()
)
@classmethod
def get_clients_managed_by_user(cls, user):
prefix = "oidcclientextension__oidcclientextensiongroup"
return OIDCClient.objects.filter(
models.Q(owner=user)
| models.Q(oidcclientextension__managers=user)
| models.Q(
**{
f"{prefix}__group_managers_can_manage_client": True,
f"{prefix}__group__managers": user,
}
)
| models.Q(
**{
f"{prefix}__group_managers_can_manage_client": True,
f"{prefix}__group__computed_parents__managers": user,
}
)
).distinct()
@classmethod
def from_client(cls, client):
if not hasattr(client, oidcclientextension):
related_name = cls._meta.get_field("client").remote_field.get_accessor_name()
if not hasattr(client, related_name):
cls(client=client).save()
return getattr(client, oidcclientextension)
return getattr(client, related_name)
class AbstractLegacyGroup(models.Model):
......
from django.core.exceptions import PermissionDenied
from .scopes import CRIScopeClaims
from .. import models as cri_auth_models
__all__ = ("CRIScopeClaims", "criuser_sub_generator", "after_userlogin_hook")
def criuser_sub_generator(user):
return user.username
def after_userlogin_hook(request, user, client):
client_extension = cri_auth_models.OIDCClientExtension.from_client(client)
if not client_extension.is_user_allowed(user):
raise PermissionDenied()
from oidc_provider.lib.claims import ScopeClaims
class CRIScope:
def __init__(self, client, user):
self.client = client
self.user = user
def get_claims(self):
return {}
def __str__(self):
return self.name
class CustomScopeClaims(ScopeClaims):
scope_classes = tuple()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.scope_classes = tuple(
[
s
for s in type(self).scope_classes
if s.scope in self.client.scope or not self.client.scope
]
)
def create_response_dic(self):
dic = {}
denied = set()
unknown = set()
scope_map = {s.scope: s for s in type(self).scope_classes}
for scope in set(self.scopes) - set(["openid"]):
scope_cls = scope_map.get(scope)
if not scope_cls:
unknown.add(scope)
elif scope_cls not in self.scope_classes:
denied.add(scope)
else:
dic.update(scope_cls(self.client, self.user).get_claims())
if denied:
dic["denied_scopes"] = list(denied)
if unknown:
dic["unknown_scopes"] = list(unknown)
return dic
def _scopes_registered(self):
return [getattr(c, "scope") for c in self.scope_classes]
@classmethod
def get_scopes_info(cls, scopes=None):
if scopes is None:
scopes = []
scopes_info = []
for scope_cls in cls.scope_classes:
if scope_cls.scope not in scopes:
continue
scopes_info.append(
{
"scope": scope_cls.scope,
"name": scope_cls.name,
"description": scope_cls.desc,
}
)
return scopes_info
from django.utils import timezone
from django.conf import settings
from oidc_provider.lib.claims import ScopeClaims
from .base import CRIScope, CustomScopeClaims
from cri_models import models as cri_models
from .models import LegacyClassGroup, LegacyRole
from .. import models as cri_auth_models
def criuser_sub_generator(user):
return str(user.username)
class Scope(object):
def __init__(self, client, user):
self.client = client
self.user = user
def get_claims(self):
return {}
def __str__(self):
return self.name
class ProfileScope(Scope):
class ProfileScope(CRIScope):
scope = "profile"
name = "Basic profile"
desc = "Access to your basic information. Includes names and other information."
......@@ -39,7 +22,7 @@ class ProfileScope(Scope):
}
class EmailScope(Scope):
class EmailScope(CRIScope):
scope = "email"
name = "Email"
desc = "Access to your EPITA email address."
......@@ -48,7 +31,7 @@ class EmailScope(Scope):
return {"email": self.user.email, "email_verified": bool(self.user.email)}
class PhoneScope(Scope):
class PhoneScope(CRIScope):
scope = "phone"
name = "Phone number"
desc = "Access to your phone number."
......@@ -60,15 +43,26 @@ class PhoneScope(Scope):
}
class EPITAScope(Scope):
class EPITAScope(CRIScope):
scope = "epita"
name = "EPITA"
desc = "Access to your EPITA-specific information. Include groups, UID, campuses and other information."
desc = (
"Access to your EPITA-specific information. Include groups, UID, "
"campuses and other information."
)
GROUP_FIELDS = ("slug", "gid", "name", "kind", "private")
def _get_roles(self):
client_extension = cri_auth_models.OIDCClientExtension.from_client(self.client)
return client_extension.get_user_roles(self.user)
def _get_groups(self):
client_extension = cri_auth_models.OIDCClientExtension.from_client(self.client)
return client_extension.get_user_groups(self.user)
def get_claims(self):
groups = self.user.get_groups().filter(private=False)
groups = self._get_groups()
graduation_years = list(
cri_models.CRICurrentComputedMembership.objects.filter(
group__in=groups, graduation_year__isnull=False
......@@ -78,58 +72,37 @@ class EPITAScope(Scope):
.values_list("graduation_year", flat=True)
)
new_account = self.user.get_new_account()
# We include the epita_legacy scope claims to avoid breaking all
# existing clients. This will be removed later.
claims = EPITALegacyScope(self.client, self.user).get_claims()
claims.update(
{
"uid": self.user.uid,
"gid": self.user.primary_group.gid,
"old_logins": [u.username for u in self.user.get_old_accounts()],
"new_login": new_account.username if new_account else None,
"groups": list(groups.values(*self.GROUP_FIELDS)),
"campuses": list(
groups.filter(kind="campus").values_list("slug", flat=True)
if cri_auth_models.OIDCClientExtension.from_client(self.client).is_legacy:
return {
"login": self.user.username,
"promo": max(graduation_years) if graduation_years else None,
"uid_number": self.user.uid,
"roles": list(
cri_auth_models.LegacyRole.objects.filter(
members=self.user
).values_list("name", flat=True)
),
"class_groups": list(
cri_auth_models.LegacyClassGroup.objects.filter(
members=self.user
).values_list("name", flat=True)
),
"graduation_years": graduation_years,
}
)
return claims
class EPITALegacyScope(Scope):
scope = "epita_legacy"
name = "EPITA (legacy)"
desc = "Access to your EPITA-specific information. THIS SCOPE IS DEPRECATED."
def get_claims(self):
groups = self.user.get_groups().filter(private=False)
graduation_years = list(
cri_models.CRICurrentComputedMembership.objects.filter(
group__in=groups, graduation_year__isnull=False
)
.distinct("graduation_year")
.order_by("graduation_year")
.values_list("graduation_year", flat=True)
)
return {
"login": self.user.username,
"promo": max(graduation_years) if graduation_years else None,
"uid_number": self.user.uid,
"roles": list(
LegacyRole.objects.filter(members=self.user).values_list(
"name", flat=True
)
),
"class_groups": list(
LegacyClassGroup.objects.filter(members=self.user).values_list(
"name", flat=True
)
"uid": self.user.uid,
"gid": self.user.primary_group.gid,
"old_logins": [u.username for u in self.user.get_old_accounts()],
"new_login": new_account.username if new_account else None,
"groups": list(groups.values(*self.GROUP_FIELDS)),
"roles": list(self._get_roles()),
"campuses": list(
groups.filter(kind="campus").values_list("slug", flat=True)
),
"graduation_years": graduation_years,
}
class BirthdateScope(Scope):
class BirthdateScope(CRIScope):
scope = "birthdate"
name = "Birthdate"
desc = "Access to your birthdate"
......@@ -138,7 +111,7 @@ class BirthdateScope(Scope):
return {"birthdate": str(self.user.birthdate or "")}
class LegalIdentityScope(Scope):
class LegalIdentityScope(CRIScope):
scope = "legal_identity"
name = "Legal identity"
desc = "Access to your identity as used on legal documents."
......@@ -150,57 +123,12 @@ class LegalIdentityScope(Scope):
}
class ExtraScopeClaims(ScopeClaims):
class CRIScopeClaims(CustomScopeClaims):
scope_classes = (
ProfileScope,
EmailScope,
PhoneScope,
EPITAScope,
EPITALegacyScope,
BirthdateScope,
LegalIdentityScope,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.scope_classes = tuple(
[
s
for s in type(self).scope_classes
if s.scope in self.client.scope or not self.client.scope
]
)
def create_response_dic(self):
dic = {}
denied = set()
for scope_cls in type(self).scope_classes:
if scope_cls not in self.scope_classes:
denied.add(scope_cls.scope)
elif scope_cls.scope in self.scopes:
dic.update(scope_cls(self.client, self.user).get_claims())
if denied:
dic["denied_scopes"] = list(denied)
return dic
def _scopes_registered(self):
return [getattr(c, "scope") for c in self.scope_classes]
@classmethod
def get_scopes_info(cls, scopes=None):
if scopes is None:
scopes = []
scopes_info = []
for scope_cls in cls.scope_classes:
if scope_cls.scope not in scopes:
continue
scopes_info.append(
{
"scope": scope_cls.scope,
"name": scope_cls.name,
"description": scope_cls.desc,
}
)
return scopes_info
......@@ -3,7 +3,7 @@
{% block title %}{{block.super }} - OpenID-Connect authorization{% endblock %}
{% block form_icon %}<i class="far fa-id-card"></i>{% endblock %}
{% block form_icon %}<i class="fab fa-openid"></i>{% endblock %}
{% block form_title %}{{ client.name }}{% endblock %}
{% block form_content %}
......@@ -40,7 +40,13 @@
</dl>
<form method="POST" class="form" action="{% url 'oidc_provider:authorize' %}">
{% csrf_token %}
{{ hidden_inputs }}
{% for input in hidden_inputs.splitlines %}
{% if 'name="scope"' in input %}
<input name="scope" type="hidden" value="{% for scope in scopes %}{% if not client.scope or scope.scope in client.scope %}{{ scope.scope }} {% endif %}{% endfor %}openid" />
{% else %}
{{ input|safe }}
{% endif %}
{% endfor %}
<nav class="nav nav-pills nav-fill mt-2">
<button class="mr-2 btn btn-lg btn-outline-danger nav-item" type="submit">
<i class="fas fa-ban"></i>
......
from django.conf import settings
from urllib.parse import urlencode
from operator import itemgetter
import base64
import hashlib
import hmac
import json
def _serialize(query_parameters):
for key, value in query_parameters.items():
if isinstance(value, (list, dict)):
value = json.dumps(value)
elif isinstance(value, bool):
value = "true" if value else "false"
query_parameters[key] = value
return urlencode(sorted(query_parameters.items(), key=itemgetter(0)))
def generate_secured_api_key(parent_api_key, restrictions):
query_parameters = _serialize(restrictions)
secured_key = hmac.new(
parent_api_key.encode("utf-8"), query_parameters.encode("utf-8"), hashlib.sha256
).hexdigest()
base64encoded = base64.b64encode(
("{}{}".format(secured_key, query_parameters)).encode("utf-8")
)
return str(base64encoded.decode("utf-8"))
def get_index_name(index):
algolia_settings = settings.ALGOLIA
index_name = index.index_name
return "_".join(
filter(
None,
(
algolia_settings.get("INDEX_PREFIX"),
index_name,
algolia_settings.get("INDEX_SUFFIX"),
),
)
)
from django.conf import settings
from django.utils.timezone import now
from datetime import timedelta
from .algolia import generate_secured_api_key, get_index_name
from cri_models import index
def search(request):
if request.user.is_anonymous:
# Avoid key generation for unauthenticated users
return {}
indices = [
(get_index_name(index.CRIUserIndex), "Users"),
(get_index_name(index.CRIGroupIndex), "Groups"),
]
restrictions = {
"validUntil": int((now() + timedelta(days=1)).timestamp()),
"userToken": request.user.username,
"restrictIndices": list([i[0] for i in indices]),
}
if not request.user.has_perm("cri_models.view_crigroup"):
restrictions.update(
{
"filters": (
f"(private:false OR members:{request.user.pk}) OR NOT "
"type:crigroup"
)
}
)
search_key = generate_secured_api_key(
settings.ALGOLIA.get("SEARCH_API_KEY"), restrictions
)
return {
"algolia_app_id": settings.ALGOLIA.get("APPLICATION_ID"),
"algolia_search_key": search_key,
"algolia_indices": indices,
}
This diff is collapsed.
/*! algoliasearch-lite.umd.js | 4.4.0 | © Algolia, inc. | https://github.com/algolia/algoliasearch-client-javascript */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).algoliasearch=t()}(this,(function(){"use strict";function e(e,t,r){return t in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function t(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function r(r){for(var n=1;n<arguments.length;n++){var o=null!=arguments[n]?arguments[n]:{};n%2?t(Object(o),!0).forEach((function(t){e(r,t,o[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(r,Object.getOwnPropertyDescriptors(o)):t(Object(o)).forEach((function(e){Object.defineProperty(r,e,Object.getOwnPropertyDescriptor(o,e))}))}return r}function n(e,t){if(null==e)return{};var r,n,o=function(e,t){if(null==e)return{};var r,n,o={},a=Object.keys(e);for(n=0;n<a.length;n++)r=a[n],t.indexOf(r)>=0||(o[r]=e[r]);return o}(e,t);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(e);for(n=0;n<a.length;n++)r=a[n],t.indexOf(r)>=0||Object.prototype.propertyIsEnumerable.call(e,r)&&(o[r]=e[r])}return o}function o(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){if(!(Symbol.iterator in Object(e)||"[object Arguments]"===Object.prototype.toString.call(e)))return;var r=[],n=!0,o=!1,a=void 0;try{for(var u,i=e[Symbol.iterator]();!(n=(u=i.next()).done)&&(r.push(u.value),!t||r.length!==t);n=!0);}catch(e){o=!0,a=e}finally{try{n||null==i.return||i.return()}finally{if(o)throw a}}return r}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}function a(e){return function(e){if(Array.isArray(e)){for(var t=0,r=new Array(e.length);t<e.length;t++)r[t]=e[t];return r}}(e)||function(e){if(Symbol.iterator in Object(e)||"[object Arguments]"===Object.prototype.toString.call(e))return Array.from(e)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance")}()}function u(e){var t,r="algoliasearch-client-js-".concat(e.key),n=function(){return void 0===t&&(t=e.localStorage||window.localStorage),t},a=function(){return JSON.parse(n().getItem(r)||"{}")};return{get:function(e,t){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return Promise.resolve().then((function(){var r=JSON.stringify(e),n=a()[r];return Promise.all([n||t(),void 0!==n])})).then((function(e){var t=o(e,2),n=t[0],a=t[1];return Promise.all([n,a||r.miss(n)])})).then((function(e){return o(e,1)[0]}))},set:function(e,t){return Promise.resolve().then((function(){var o=a();return o[JSON.stringify(e)]=t,n().setItem(r,JSON.stringify(o)),t}))},delete:function(e){return Promise.resolve().then((function(){var t=a();delete t[JSON.stringify(e)],n().setItem(r,JSON.stringify(t))}))},clear:function(){return Promise.resolve().then((function(){n().removeItem(r)}))}}}function i(e){var t=a(e.caches),r=t.shift();return void 0===r?{get:function(e,t){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}},n=t();return n.then((function(e){return Promise.all([e,r.miss(e)])})).then((function(e){return o(e,1)[0]}))},set:function(e,t){return Promise.resolve(t)},delete:function(e){return Promise.resolve()},clear:function(){return Promise.resolve()}}:{get:function(e,n){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return r.get(e,n,o).catch((function(){return i({caches:t}).get(e,n,o)}))},set:function(e,n){return r.set(e,n).catch((function(){return i({caches:t}).set(e,n)}))},delete:function(e){return r.delete(e).catch((function(){return i({caches:t}).delete(e)}))},clear:function(){return r.clear().catch((function(){return i({caches:t}).clear()}))}}}function s(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{serializable:!0},t={};return{get:function(r,n){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}},a=JSON.stringify(r);if(a in t)return Promise.resolve(e.serializable?JSON.parse(t[a]):t[a]);var u=n(),i=o&&o.miss||function(){return Promise.resolve()};return u.then((function(e){return i(e)})).then((function(){return u}))},set:function(r,n){return t[JSON.stringify(r)]=e.serializable?JSON.stringify(n):n,Promise.resolve(n)},delete:function(e){return delete t[JSON.stringify(e)],Promise.resolve()},clear:function(){return t={},Promise.resolve()}}}function c(e){for(var t=e.length-1;t>0;t--){var r=Math.floor(Math.random()*(t+1)),n=e[t];e[t]=e[r],e[r]=n}return e}function l(e,t){return Object.keys(void 0!==t?t:{}).forEach((function(r){e[r]=t[r](e)})),e}function f(e){for(var t=arguments.length,r=new Array(t>1?t-1:0),n=1;n<t;n++)r[n-1]=arguments[n];var o=0;return e.replace(/%s/g,(function(){return encodeURIComponent(r[o++])}))}var h={WithinQueryParameters:0,WithinHeaders:1};function d(e,t){var r=e||{},n=r.data||{};return Object.keys(r).forEach((function(e){-1===["timeout","headers","queryParameters","data","cacheable"].indexOf(e)&&(n[e]=r[e])})),{data:Object.entries(n).length>0?n:void 0,timeout:r.timeout||t,headers:r.headers||{},queryParameters:r.queryParameters||{},cacheable:r.cacheable}}var m={Read:1,Write:2,Any:3},p=1,v=2,g=3;function y(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:p;return r({},e,{status:t,lastUpdate:Date.now()})}function b(e){return{protocol:e.protocol||"https",url:e.url,accept:e.accept||m.Any}}var O="GET",P="POST";function q(e,t){return Promise.all(t.map((function(t){return e.get(t,(function(){return Promise.resolve(y(t))}))}))).then((function(e){var r=e.filter((function(e){return function(e){return e.status===p||Date.now()-e.lastUpdate>12e4}(e)})),n=e.filter((function(e){return function(e){return e.status===g&&Date.now()-e.lastUpdate<=12e4}(e)})),o=[].concat(a(r),a(n));return{getTimeout:function(e,t){return(0===n.length&&0===e?1:n.length+3+e)*t},statelessHosts:o.length>0?o.map((function(e){return b(e)})):t}}))}function j(e,t,n,o){var u=[],i=function(e,t){if(e.method===O||void 0===e.data&&void 0===t.data)return;var n=Array.isArray(e.data)?e.data:r({},e.data,{},t.data);return JSON.stringify(n)}(n,o),s=function(e,t){var n=r({},e.headers,{},t.headers),o={};return Object.keys(n).forEach((function(e){var t=n[e];o[e.toLowerCase()]=t})),o}(e,o),c=n.method,l=n.method!==O?{}:r({},n.data,{},o.data),f=r({"x-algolia-agent":e.userAgent.value},e.queryParameters,{},l,{},o.queryParameters),h=0,d=function t(r,a){var l=r.pop();if(void 0===l)throw{name:"RetryError",message:"Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.",transporterStackTrace:A(u)};var d={data:i,headers:s,method:c,url:w(l,n.path,f),connectTimeout:a(h,e.timeouts.connect),responseTimeout:a(h,o.timeout)},m=function(e){var t={request:d,response:e,host:l,triesLeft:r.length};return u.push(t),t},p={onSucess:function(e){return function(e){try{return JSON.parse(e.content)}catch(t){throw function(e,t){return{name:"DeserializationError",message:e,response:t}}(t.message,e)}}(e)},onRetry:function(n){var o=m(n);return n.isTimedOut&&h++,Promise.all([e.logger.info("Retryable failure",x(o)),e.hostsCache.set(l,y(l,n.isTimedOut?g:v))]).then((function(){return t(r,a)}))},onFail:function(e){throw m(e),function(e,t){var r=e.content,n=e.status,o=r;try{o=JSON.parse(r).message}catch(e){}return function(e,t,r){return{name:"ApiError",message:e,status:t,transporterStackTrace:r}}(o,n,t)}(e,A(u))}};return e.requester.send(d).then((function(e){return function(e,t){return function(e){var t=e.status;return e.isTimedOut||function(e){var t=e.isTimedOut,r=e.status;return!t&&0==~~r}(e)||2!=~~(t/100)&&4!=~~(t/100)}(e)?t.onRetry(e):2==~~(e.status/100)?t.onSucess(e):t.onFail(e)}(e,p)}))};return q(e.hostsCache,t).then((function(e){return d(a(e.statelessHosts).reverse(),e.getTimeout)}))}function S(e){var t={value:"Algolia for JavaScript (".concat(e,")"),add:function(e){var r="; ".concat(e.segment).concat(void 0!==e.version?" (".concat(e.version,")"):"");return-1===t.value.indexOf(r)&&(t.value="".concat(t.value).concat(r)),t}};return t}function w(e,t,r){var n=T(r),o="".concat(e.protocol,"://").concat(e.url,"/").concat("/"===t.charAt(0)?t.substr(1):t);return n.length&&(o+="?".concat(n)),o}function T(e){return Object.keys(e).map((function(t){return f("%s=%s",t,(r=e[t],"[object Object]"===Object.prototype.toString.call(r)||"[object Array]"===Object.prototype.toString.call(r)?JSON.stringify(e[t]):e[t]));var r})).join("&")}function A(e){return e.map((function(e){return x(e)}))}function x(e){var t=e.request.headers["x-algolia-api-key"]?{"x-algolia-api-key":"*****"}:{};return r({},e,{request:r({},e.request,{headers:r({},e.request.headers,{},t)})})}var C=function(e){var t=e.appId,n=function(e,t,r){var n={"x-algolia-api-key":r,"x-algolia-application-id":t};return{headers:function(){return e===h.WithinHeaders?n:{}},queryParameters:function(){return e===h.WithinQueryParameters?n:{}}}}(void 0!==e.authMode?e.authMode:h.WithinHeaders,t,e.apiKey),a=function(e){var t=e.hostsCache,r=e.logger,n=e.requester,a=e.requestsCache,u=e.responsesCache,i=e.timeouts,s=e.userAgent,c=e.hosts,l=e.queryParameters,f={hostsCache:t,logger:r,requester:n,requestsCache:a,responsesCache:u,timeouts:i,userAgent:s,headers:e.headers,queryParameters:l,hosts:c.map((function(e){return b(e)})),read:function(e,t){var r=d(t,f.timeouts.read),n=function(){return j(f,f.hosts.filter((function(e){return 0!=(e.accept&m.Read)})),e,r)};if(!0!==(void 0!==r.cacheable?r.cacheable:e.cacheable))return n();var a={request:e,mappedRequestOptions:r,transporter:{queryParameters:f.queryParameters,headers:f.headers}};return f.responsesCache.get(a,(function(){return f.requestsCache.get(a,(function(){return f.requestsCache.set(a,n()).then((function(e){return Promise.all([f.requestsCache.delete(a),e])}),(function(e){return Promise.all([f.requestsCache.delete(a),Promise.reject(e)])})).then((function(e){var t=o(e,2);t[0];return t[1]}))}))}),{miss:function(e){return f.responsesCache.set(a,e)}})},write:function(e,t){return j(f,f.hosts.filter((function(e){return 0!=(e.accept&m.Write)})),e,d(t,f.timeouts.write))}};return f}(r({hosts:[{url:"".concat(t,"-dsn.algolia.net"),accept:m.Read},{url:"".concat(t,".algolia.net"),accept:m.Write}].concat(c([{url:"".concat(t,"-1.algolianet.com")},{url:"".concat(t,"-2.algolianet.com")},{url:"".concat(t,"-3.algolianet.com")}]))},e,{headers:r({},n.headers(),{},{"content-type":"application/x-www-form-urlencoded"},{},e.headers),queryParameters:r({},n.queryParameters(),{},e.queryParameters)}));return l({transporter:a,appId:t,addAlgoliaAgent:function(e,t){a.userAgent.add({segment:e,version:t})},clearCache:function(){return Promise.all([a.requestsCache.clear(),a.responsesCache.clear()]).then((function(){}))}},e.methods)},N=function(e){return function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n={transporter:e.transporter,appId:e.appId,indexName:t};return l(n,r.methods)}},k=function(e){return function(t,n){var o=t.map((function(e){return r({},e,{params:T(e.params||{})})}));return e.transporter.read({method:P,path:"1/indexes/*/queries",data:{requests:o},cacheable:!0},n)}},J=function(e){return function(t,o){return Promise.all(t.map((function(t){var a=t.params,u=a.facetName,i=a.facetQuery,s=n(a,["facetName","facetQuery"]);return N(e)(t.indexName,{methods:{searchForFacetValues:I}}).searchForFacetValues(u,i,r({},o,{},s))})))}},E=function(e){return function(t,r){return e.transporter.read({method:P,path:f("1/indexes/%s/query",e.indexName),data:{query:t},cacheable:!0},r)}},I=function(e){return function(t,r,n){return e.transporter.read({method:P,path:f("1/indexes/%s/facets/%s/query",e.indexName,t),data:{facetQuery:r},cacheable:!0},n)}},F=1,R=2,D=3;function W(e,t,n){var o,a={appId:e,apiKey:t,timeouts:{connect:1,read:2,write:30},requester:{send:function(e){return new Promise((function(t){var r=new XMLHttpRequest;r.open(e.method,e.url,!0),Object.keys(e.headers).forEach((function(t){return r.setRequestHeader(t,e.headers[t])}));var n,o=function(e,n){return setTimeout((function(){r.abort(),t({status:0,content:n,isTimedOut:!0})}),1e3*e)},a=o(e.connectTimeout,"Connection timeout");r.onreadystatechange=function(){r.readyState>r.OPENED&&void 0===n&&(clearTimeout(a),n=o(e.responseTimeout,"Socket timeout"))},r.onerror=function(){0===r.status&&(clearTimeout(a),clearTimeout(n),t({content:r.responseText||"Network request failed",status:r.status,isTimedOut:!1}))},r.onload=function(){clearTimeout(a),clearTimeout(n),t({content:r.responseText,status:r.status,isTimedOut:!1})},r.send(e.data)}))}},logger:(o=D,{debug:function(e,t){return F>=o&&console.debug(e,t),Promise.resolve()},info:function(e,t){return R>=o&&console.info(e,t),Promise.resolve()},error:function(e,t){return console.error(e,t),Promise.resolve()}}),responsesCache:s(),requestsCache:s({serializable:!1}),hostsCache:i({caches:[u({key:"".concat("4.4.0","-").concat(e)}),s()]}),userAgent:S("4.4.0").add({segment:"Browser",version:"lite"}),authMode:h.WithinQueryParameters};return C(r({},a,{},n,{methods:{search:k,searchForFacetValues:J,multipleQueries:k,multipleSearchForFacetValues:J,initIndex:function(e){return function(t){return N(e)(t,{methods:{search:E,searchForFacetValues:I}})}}}}))}return W.version="4.4.0",W}));
This diff is collapsed.
<svg xmlns="http://www.w3.org/2000/svg" width="168" height="24"><g fill="none"><path fill="#5468FF" d="M78.988.938h16.594a2.968 2.968 0 012.966 2.966V20.5a2.967 2.967 0 01-2.966 2.964H78.988a2.967 2.967 0 01-2.966-2.964V3.897A2.961 2.961 0 0178.988.938zm41.937 17.866c-4.386.02-4.386-3.54-4.386-4.106l-.007-13.336 2.675-.424v13.254c0 .322 0 2.358 1.718 2.364v2.248zm-10.846-2.18c.821 0 1.43-.047 1.855-.129v-2.719a6.334 6.334 0 00-1.574-.199 5.7 5.7 0 00-.897.069 2.699 2.699 0 00-.814.24c-.24.116-.439.28-.582.491-.15.212-.219.335-.219.656 0 .628.219.991.616 1.23s.938.362 1.615.362zm-.233-9.7c.883 0 1.629.109 2.231.328.602.218 1.088.525 1.444.915.363.396.609.922.76 1.483.157.56.232 1.175.232 1.85v6.874a32.5 32.5 0 01-1.868.314c-.834.123-1.772.185-2.813.185-.69 0-1.327-.069-1.895-.198a4.001 4.001 0 01-1.471-.636 3.085 3.085 0 01-.951-1.134c-.226-.465-.343-1.12-.343-1.803 0-.656.13-1.073.384-1.525a3.24 3.24 0 011.047-1.106c.445-.287.95-.492 1.532-.615a8.8 8.8 0 011.82-.185 8.404 8.404 0 011.972.24v-.438c0-.307-.035-.6-.11-.874a1.88 1.88 0 00-.384-.73 1.784 1.784 0 00-.724-.493 3.164 3.164 0 00-1.143-.205c-.616 0-1.177.075-1.69.164a7.735 7.735 0 00-1.26.307l-.321-2.192c.335-.117.834-.233 1.478-.349a10.98 10.98 0 012.073-.178zm52.842 9.626c.822 0 1.43-.048 1.854-.13V13.7a6.347 6.347 0 00-1.574-.199c-.294 0-.595.021-.896.069a2.7 2.7 0 00-.814.24 1.46 1.46 0 00-.582.491c-.15.212-.218.335-.218.656 0 .628.218.991.615 1.23.404.245.938.362 1.615.362zm-.226-9.694c.883 0 1.629.108 2.231.327.602.219 1.088.526 1.444.915.355.39.609.923.759 1.483a6.8 6.8 0 01.233 1.852v6.873c-.41.088-1.034.19-1.868.314-.834.123-1.772.184-2.813.184-.69 0-1.327-.068-1.895-.198a4.001 4.001 0 01-1.471-.635 3.085 3.085 0 01-.951-1.134c-.226-.465-.343-1.12-.343-1.804 0-.656.13-1.073.384-1.524.26-.45.608-.82 1.047-1.107.445-.286.95-.491 1.532-.614a8.803 8.803 0 012.751-.13c.329.034.671.096 1.04.185v-.437a3.3 3.3 0 00-.109-.875 1.873 1.873 0 00-.384-.731 1.784 1.784 0 00-.724-.492 3.165 3.165 0 00-1.143-.205c-.616 0-1.177.075-1.69.164a7.75 7.75 0 00-1.26.307l-.321-2.193c.335-.116.834-.232 1.478-.348a11.633 11.633 0 012.073-.177zm-8.034-1.271a1.626 1.626 0 01-1.628-1.62c0-.895.725-1.62 1.628-1.62.904 0 1.63.725 1.63 1.62 0 .895-.733 1.62-1.63 1.62zm1.348 13.22h-2.689V7.27l2.69-.423v11.956zm-4.714 0c-4.386.02-4.386-3.54-4.386-4.107l-.008-13.336 2.676-.424v13.254c0 .322 0 2.358 1.718 2.364v2.248zm-8.698-5.903c0-1.156-.253-2.119-.746-2.788-.493-.677-1.183-1.01-2.067-1.01-.882 0-1.574.333-2.065 1.01-.493.676-.733 1.632-.733 2.788 0 1.168.246 1.953.74 2.63.492.683 1.183 1.018 2.066 1.018.882 0 1.574-.342 2.067-1.019.492-.683.738-1.46.738-2.63zm2.737-.007c0 .902-.13 1.584-.397 2.33a5.52 5.52 0 01-1.128 1.906 4.986 4.986 0 01-1.752 1.223c-.685.286-1.739.45-2.265.45-.528-.006-1.574-.157-2.252-.45a5.096 5.096 0 01-1.744-1.223c-.487-.527-.863-1.162-1.137-1.906a6.345 6.345 0 01-.41-2.33c0-.902.123-1.77.397-2.508a5.554 5.554 0 011.15-1.892 5.133 5.133 0 011.75-1.216c.679-.287 1.425-.423 2.232-.423.808 0 1.553.142 2.237.423a4.88 4.88 0 011.753 1.216 5.644 5.644 0 011.135 1.892c.287.738.431 1.606.431 2.508zm-20.138 0c0 1.12.246 2.363.738 2.882.493.52 1.13.78 1.91.78.424 0 .828-.062 1.204-.178.377-.116.677-.253.917-.417V9.33a10.476 10.476 0 00-1.766-.226c-.971-.028-1.71.37-2.23 1.004-.513.636-.773 1.75-.773 2.788zm7.438 5.274c0 1.824-.466 3.156-1.404 4.004-.936.846-2.367 1.27-4.296 1.27-.705 0-2.17-.137-3.34-.396l.431-2.118c.98.205 2.272.26 2.95.26 1.074 0 1.84-.219 2.299-.656.459-.437.684-1.086.684-1.948v-.437a8.07 8.07 0 01-1.047.397c-.43.13-.93.198-1.492.198-.739 0-1.41-.116-2.018-.349a4.206 4.206 0 01-1.567-1.025c-.431-.45-.774-1.017-1.013-1.694-.24-.677-.363-1.885-.363-2.773 0-.834.13-1.88.384-2.577.26-.696.629-1.298 1.129-1.796.493-.498 1.095-.881 1.8-1.162a6.605 6.605 0 012.428-.457c.87 0 1.67.109 2.45.24.78.129 1.444.265 1.985.415V18.17z"/><path fill="#5D6494" d="M6.972 6.677v1.627c-.712-.446-1.52-.67-2.425-.67-.585 0-1.045.13-1.38.391a1.24 1.24 0 00-.502 1.03c0 .425.164.765.494 1.02.33.256.835.532 1.516.83.447.192.795.356 1.045.495.25.138.537.332.862.582.324.25.563.548.718.894.154.345.23.741.23 1.188 0 .947-.334 1.691-1.004 2.234-.67.542-1.537.814-2.601.814-1.18 0-2.16-.229-2.936-.686v-1.708c.84.628 1.814.942 2.92.942.585 0 1.048-.136 1.388-.407.34-.271.51-.646.51-1.125 0-.287-.1-.55-.302-.79-.203-.24-.42-.42-.655-.542-.234-.123-.585-.29-1.053-.503a61.27 61.27 0 01-.582-.271 13.67 13.67 0 01-.55-.287 4.275 4.275 0 01-.567-.351 6.92 6.92 0 01-.455-.4c-.18-.17-.31-.34-.39-.51-.08-.17-.155-.37-.224-.598a2.553 2.553 0 01-.104-.742c0-.915.333-1.638.998-2.17.664-.532 1.523-.798 2.576-.798.968 0 1.793.17 2.473.51zm7.468 5.696v-.287c-.022-.607-.187-1.088-.495-1.444-.309-.357-.75-.535-1.324-.535-.532 0-.99.194-1.373.583-.382.388-.622.949-.717 1.683h3.909zm1.005 2.792v1.404c-.596.34-1.383.51-2.362.51-1.255 0-2.255-.377-3-1.132-.744-.755-1.116-1.744-1.116-2.968 0-1.297.34-2.316 1.021-3.055.68-.74 1.548-1.11 2.6-1.11 1.033 0 1.852.323 2.458.966.606.644.91 1.572.91 2.784 0 .33-.033.676-.096 1.038h-5.314c.107.702.405 1.239.894 1.611.49.372 1.106.558 1.85.558.862 0 1.58-.202 2.155-.606zm6.605-1.77h-1.212c-.596 0-1.045.116-1.349.35-.303.234-.454.532-.454.894 0 .372.117.664.35.877.235.213.575.32 1.022.32.51 0 .912-.142 1.204-.424.293-.281.44-.651.44-1.108v-.91zm-4.068-2.554V9.325c.627-.361 1.457-.542 2.489-.542 2.116 0 3.175 1.026 3.175 3.08V17h-1.548v-.957c-.415.68-1.143 1.02-2.186 1.02-.766 0-1.38-.22-1.843-.661-.462-.442-.694-1.003-.694-1.684 0-.776.293-1.38.878-1.81.585-.431 1.404-.647 2.457-.647h1.34V11.8c0-.554-.133-.971-.399-1.253-.266-.282-.707-.423-1.324-.423a4.07 4.07 0 00-2.345.718zm9.333-1.93v1.42c.394-1 1.101-1.5 2.123-1.5.148 0 .313.016.494.048v1.531a1.885 1.885 0 00-.75-.143c-.542 0-.989.24-1.34.718-.351.479-.527 1.048-.527 1.707V17h-1.563V8.91h1.563zm5.01 4.084c.022.82.272 1.492.75 2.019.479.526 1.15.79 2.01.79.639 0 1.235-.176 1.788-.527v1.404c-.521.319-1.186.479-1.995.479-1.265 0-2.276-.4-3.031-1.197-.755-.798-1.133-1.792-1.133-2.984 0-1.16.38-2.151 1.14-2.975.761-.825 1.79-1.237 3.088-1.237.702 0 1.346.149 1.93.447v1.436a3.242 3.242 0 00-1.77-.495c-.84 0-1.513.266-2.019.798-.505.532-.758 1.213-.758 2.042zM40.24 5.72v4.579c.458-1 1.293-1.5 2.505-1.5.787 0 1.42.245 1.899.734.479.49.718 1.17.718 2.042V17h-1.564v-5.106c0-.553-.14-.98-.422-1.284-.282-.303-.652-.455-1.11-.455-.531 0-1.002.202-1.411.606-.41.405-.615 1.022-.615 1.851V17h-1.563V5.72h1.563zm14.966 10.02c.596 0 1.096-.253 1.5-.758.404-.506.606-1.157.606-1.955 0-.915-.202-1.62-.606-2.114-.404-.495-.92-.742-1.548-.742-.553 0-1.05.224-1.491.67-.442.447-.662 1.133-.662 2.058 0 .958.212 1.67.638 2.138.425.469.946.703 1.563.703zM53.004 5.72v4.42c.574-.894 1.388-1.341 2.44-1.341 1.022 0 1.857.383 2.506 1.149.649.766.973 1.781.973 3.047 0 1.138-.309 2.109-.925 2.912-.617.803-1.463 1.205-2.537 1.205-1.075 0-1.894-.447-2.457-1.34V17h-1.58V5.72h1.58zm9.908 11.104l-3.223-7.913h1.739l1.005 2.632 1.26 3.415c.096-.32.48-1.458 1.15-3.415l.909-2.632h1.66l-2.92 7.866c-.777 2.074-1.963 3.11-3.559 3.11a2.92 2.92 0 01-.734-.079v-1.34c.17.042.351.064.543.064 1.032 0 1.755-.57 2.17-1.708z"/><path fill="#FFF" d="M89.632 5.967v-.772a.978.978 0 00-.978-.977h-2.28a.978.978 0 00-.978.977v.793c0 .088.082.15.171.13a7.127 7.127 0 011.984-.28c.65 0 1.295.088 1.917.259.082.02.164-.04.164-.13m-6.248 1.01l-.39-.389a.977.977 0 00-1.382 0l-.465.465a.973.973 0 000 1.38l.383.383c.062.061.15.047.205-.014.226-.307.472-.601.746-.874.281-.28.568-.526.883-.751.068-.042.075-.137.02-.2m4.16 2.453v3.341c0 .096.104.165.192.117l2.97-1.537c.068-.034.089-.117.055-.184a3.695 3.695 0 00-3.08-1.866c-.068 0-.136.054-.136.13m0 8.048a4.489 4.489 0 01-4.49-4.482 4.488 4.488 0 014.49-4.482 4.488 4.488 0 014.489 4.482 4.484 4.484 0 01-4.49 4.482m0-10.85a6.363 6.363 0 100 12.729 6.37 6.37 0 006.372-6.368 6.358 6.358 0 00-6.371-6.36"/></g></svg>
\ No newline at end of file
......@@ -29,27 +29,72 @@
<div class="collapse navbar-collapse justify-content-between align-item-center" id="navbarCollapse">
<ul class="navbar-nav text-nowrap">
<li class="nav-item">
<a class="nav-link" href="{% url 'search' %}">
<i class="fas fa-search"></i>
Advanced search
<a class="nav-link" href="https://map.epita.eu" target="_blank">
<i class="fas fa-map"></i>
<span class="d-md-none d-lg-inline">
Maps
</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'maps' %}">
<i class="far fa-map"></i>
Maps
<a class="nav-link" href="https://doc.cri.epita.fr" target="_blank">
<i class="fas fa-question-circle"></i>
<span class="d-md-none d-lg-inline">
Documentation
</span>
</a>
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="https://doc.cri.epita.fr" target="_blank">
<i class="far fa-question-circle"></i>
Documentation
<a class="nav-link" href="#">
<i class="fas fa-address-book"></i>
<span class="d-md-none d-lg-inline">
Phone book
</span>
</a>
</li>
<li class="nav-item d-md-none">
<a class="nav-link" href="#">
<i class="fas fa-search"></i>
Search
</a>
</li>
{% endif %}
</ul>
<div class="d-flex w-100 mx-5">
<div class="d-none d-md-flex flex-grow-1 mx-4">
{% if user.is_authenticated %}
<input id="search-input" style="min-width: 20rem;" class="form-control" placeholder="Search" />
<form method="POST" role="form" class="form-inline w-100">
{% csrf_token %}
<div class="d-flex flex-grow-1 justify-content-center">
<div style="min-width: 1rem; max-width: 50rem;" class="dropdown w-100">
<div class="input-group">
<div class="input-group-prepend">
<button class="btn btn-secondary" type="submit">
<i class="fas fa-search"></i>
</button>
</div>
<input id="search-input" class="form-control" placeholder="Search" data-toggle="dropdown" autocomplete="off">
</div>
<button type="button" class="d-none dropdown-toggle" data-toggle="dropdown" id="search-results-dropdown"></button>
<div class="dropdown-menu w-100 py-0">
<div class="list-group">
{% for index, title in algolia_indices %}
<div id="{{ index }}-results" class="p-2 list-group-item d-flex justify-content-between align-items-center">
<h5 class="my-0">{{ title }}</h5>
<span>
<span id="{{ index}}-subtitle" class="mr-4"></span>
<span id="{{ index }}-nbhits"></span>
</span>
</div>
{% endfor %}
<a target="_blank" href="https://algolia.com" class="p-0 bg-light list-group-item list-group-item-action text-right">
<img class="m-2" style="height: 15px" src="{% static 'svg/search-by-algolia-light-background.svg' %}" alt="powered by Algolia">
</a>
</div>
</div>
</div>
</div>
</form>
{% endif %}
</div>
<div class="d-flex justify-content-end text-nowrap">
......@@ -113,13 +158,136 @@
</div>
</main>
{% block js %}
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="{% static 'js/jquery-3.3.1.slim.min.js' %}" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="{% static 'js/bootstrap.bundle.min.js' %}" crossorigin="anonymous"></script>
<script type="text/javascript">
$(function () {
$('[data-toggle="tooltip"]').tooltip()
})
</script>
{% if user.is_authenticated %}
<script src="{% static 'js/algoliasearch-lite.umd.js' %}"></script>
<script type="text/javascript">
search_client = algoliasearch(
'{{ algolia_app_id }}',
'{{ algolia_search_key }}'
);
search_history = new Object();
current_query = null;
function search(query, callback) {
{% for index, title in algolia_indices %}
$('#{{ index }}-nbhits').html(`
<span class="spinner-border spinner-border-sm text-primary" role="status">
<span class="sr-only">Loading...</span>
</span>
`)
{% endfor %}
if (search_history[query]) {
callback(search_history[query]);
return;
}
const queries = [
{% for index, title in algolia_indices %}
{
indexName: '{{ index }}',
query: query,
params: {
hitsPerPage: 10
}
},
{% endfor %}
]
if (current_query)
clearTimeout(current_query);
current_query = window.setTimeout(function () {
search_client.multipleQueries(queries).then(function (results) {
search_history[query] = results;
callback(results);
});
current_query = null;
},
250);
}
function trigger_search() {
query = $(this).val();
if (query === "")
$("#search-results-dropdown").dropdown('hide');
else {
$("#search-results-dropdown").dropdown('show');
search(query, function(results) {
$(".search-result").remove();
results["results"].forEach((result, index) => {
$(`#${result['index']}-nbhits`).html(`
<span class="badge badge-primary">
${result['nbHits']}
</span>
`);
if (result['hits'].length === 0) {
$(`#${result['index']}-subtitle`).html(`
<small class="search-result text-danger">No results found</small>
`)
}
result['hits'].reverse().forEach((hit, index) => {
if (hit['type'] === 'criuser') {
$(`#${result['index']}-results`).after(`
<a class="p-2 d-flex align-items-start search-result list-group-item list-group-item-action" href="${hit['url']}">
<img class="img-fluid" src="${hit['picture']}" style="max-width: 50px" />
<div class="mx-2">
<span class="text-monospace">${hit['username']}</span>
<br />
${hit['name']}
</div>
</a>
`);
}
if (hit['type'] === 'crigroup') {
var append_end = "";
var append_text = `
<small class="mr-1">
<span class="text-monospace">${hit['slug']}</span>
</small>
`
if (hit['gid'])
append_text += `
<br />
<span class="badge badge-secondary">
GID: ${hit['gid']}
</span>
`;
if (hit['private'])
append_end += `
<span class="text-danger" data-toggle="tooltip" data-placement="left" title="This group is private">
<i class="fas fa-eye-slash"></i>
</span>
`;
$(`#${result['index']}-results`).after(`
<a class="p-2 d-flex align-items-center search-result list-group-item list-group-item-action" href="${hit['url']}">
<i style="font-size: 20px;" class="fas fa-users"></i>
<span class="mx-2 flex-grow-1">
${hit['name']}
<small>
</small>
</span>
<small class="mx-2 text-muted text-right">
${append_text}
</small>
${append_end}
</a>
`);
}
});
});
$('[data-toggle="tooltip"]').tooltip()
});
}
}
$('#search-input').on('input', trigger_search);
$("#search-results-dropdown").dropdown();
</script>
{% endif %}
{% endblock %}
</body>
{% endblock %}
......
......@@ -16,6 +16,17 @@
</span>
</a>
{% endif %}
{% if criuser == user or perms.cri_models.manage_criuser_oidc %}
<a class="list-group-item list-group-item-action{% if current_page == "oidc" %} active{% else %} text-primary{% endif %} d-flex justify-content-between align-items-center" href="{% url 'criuser_oidc' criuser.username %}" role="tab">
<span>
<i class="fab fa-openid"></i>
OpenID Connect
</span>
<span data-toggle="tooltip" data-placement="right" title="This link is not visible for everyone">
<i class="fas fa-eye-slash"></i>
</span>
</a>
{% endif %}
{% with count=criuser.sshpublickey_set.count %}
<a class="list-group-item list-group-item-action{% if current_page == "ssh-keys" %} active{% else %}{% if not count %} text-danger{% else %} text-primary{% endif %}{% endif %}" href="{% url 'criuser_ssh_keys' criuser.username %}" role="tab">
<i class="fas fa-key"></i>
......
......@@ -47,7 +47,7 @@
</tr>
<tr>
<th>Slug</th>
<td class="w-60">{{ crigroup.slug }}</td>
<td class="w-60 text-monospace">{{ crigroup.slug }}</td>
</tr>
{% if crigroup.gid %}
<tr>
......
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="row justify-content-center">
<div class="col-xl-3 col-md-12" style="padding-bottom: 10px;">
<div class="card shadow">
{% include "cri_frontend/_criuser_nav.html" with current_page="oidc" %}
</div>
</div>
<div class="col-xl-8 col-md-12">
<div class="card shadow">
<div class="card-header text-center">
<h3 class="jumbotron-heading">
<i class="fas fa-user"></i>
{{ criuser.get_full_name }}
</h3>
</div>
<div class="card-body">
<h4>Consent</h4>
<p><em>The table below shows every OpenID Connect client allowed by the user to access claims of the listed scopes</em></p>
</p>
<table class="table table-hover">
<thead>
<tr class="table-secondary text-center">
<th>Client name</th>
<th>Date</th>
<th>Scopes</th>
<th></th>
</tr>
</thead>
<tbody>
{% for userconsent, scopes, claims in user_consent_list %}
<tr>
<td class="align-middle text-nowrap">
<a class="disabled btn btn-block btn-outline-primary" href="#">
<i class="fab fa-openid"></i>
{{ userconsent.client.name }}
</a>
</td>
<td class="align-middle text-nowrap">
{{ userconsent.date_given }}
<br />
<small class="text-secondary">
<strong>expire</strong>: {{ userconsent.expires_at }}
<small>
</td>
<td class="align-middle text-monospace">
<div class="d-flex justify-content-center align-items-center flex-wrap">
{% for scope in scopes %}
{% if scope in claims.denied_scopes %}
<span class="mr-1 mb-1 badge badge-danger" data-toggle="tooltip" data-placement="top" title="denied scope">
{{ scope }}
</span>
{% elif scope in claims.unknown_scopes %}
<span class="mr-1 mb-1 badge badge-warning" data-toggle="tooltip" data-placement="top" title="unknown scope">
{{ scope }}
</span>
{% else %}
<span class="mr-1 mb-1 badge badge-secondary" data-toggle="tooltip" data-placement="top">
{{ scope }}
</span>
{% endif %}
{% endfor %}
</td>
<td class="align-middle d-flex justify-content-end">
{% if userconsent.client.oidcclientextension.is_legacy %}
<button class="mr-1 disabled btn btn-sm btn-outline-danger" data-toggle="tooltip" data-placement="top" title="this client is using the legacy interface">Legacy</button>
{% endif %}
<span data-toggle="tooltip" data-placement="top" title="see claims values">
<button class="btn btn-sm btn-info mr-1" type="button" data-toggle="modal" data-target="#claims-{{ userconsent.pk }}">
<i class="fab fa-openid"></i>
</button>
</span>
<form class="form" method="POST" action="{% url 'criuser_oidc_delete_consent' userconsent.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-danger" data-toggle="tooltip" data-placement="top" title="revoke consent">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center text-danger">
<em>
<i class="fas fa-info-circle"></i>
No OpenID Connect client was authorized
</em>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h4 class="mt-5 d-flex justify-content-between">
<span>
OIDC Clients
</span>
<span data-toggle="tooltip" data-placement="left" title="Not yet available">
<a class="disabled btn btn-success btn-sm" href="#">
<i class="fas fa-plus"></i>
New client
</a>
</span>
</h4>
<table class="table table-hover">
<thead>
<tr class="table-secondary text-center">
<th>Client name</th>
<th>Client type</th>
<th>Response types</th>
<th>Status</th>
<th>Owner</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr>
<td class="align-middle text-nowrap">
<a class="disabled btn btn-block btn-outline-primary" href="#">
<i class="fab fa-openid"></i>
{{ client.name }}
</a>
</td>
<td class="align-middle text-center text-nowrap">
{{ client.get_client_type_display }}
</td>
<td class="align-middle">
<div class="d-flex justify-content-center align-items-center flex-wrap">
{% for response_type in client.response_types.all %}
<span class="mb-1 mr-1 badge badge-dark">
{{ response_type }}
</span>
{% endfor %}
</div>
</td>
<td class="align-middle">
<div class="d-flex justify-content-center flex-nowrap">
{% if client.oidcclientextension.is_legacy %}
<button class=" mr-1 disabled btn btn-sm btn-outline-danger" data-toggle="tooltip" data-placement="top" title="this client is using the legacy interface">Legacy</button>
{% endif %}
{% if not client.oidcclientextension.is_restricted %}
<button class="mr-1 disabled btn btn-sm btn-outline-warning" data-toggle="tooltip" data-placement="top" title="this client only allow some users to sign-in">Restricted</button>
{% endif %}
</div>
</td>
<td class="align-middle text-nowrap">
{% if client.owner %}
<a class="btn btn-block btn-outline-secondary" href="{{ client.owner.get_absolute_url }}">
<i class="fas fa-user"></i>
{{ client.owner.username }}
</a>
{% else %}
<button class="disabled btn btn-block btn-outline-secondary">
<i class="fas fa-user"></i>
<em>No owner</em>
</button>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-danger">
<em>
<i class="fas fa-info-circle"></i>
You do not have access to any OIDC client.
</em>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% for userconsent, scopes, claims in user_consent_list %}
<div class="modal" id="claims-{{ userconsent.pk }}">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fab fa-openid"></i>
{{ userconsent.client.name }}
</h5>
</div>
<div class="modal-body">
{% if userconsent.client.oidcclientextension.is_legacy %}
<div class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-circle"></i>
This OIDC client is using the legacy interface.
</div>
{% endif %}
{% with claims=claims|pprint %}
<textarea readonly class="text-monospace w-100" rows="{{ claims.splitlines|length|add:1 }}">{{ claims }}</textarea>
{% endwith %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}