diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87df887..9716f35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ Replace the `MEDIA_ROOT` and `DATA_EXPORT_ROOT` settings with proper locations. For example: *(make sure the directory exists!)* - MEDIA_ROOT = "/tmp/amelie_uploads/" + MEDIA_ROOT = "/tmp/amelie_uploads" ##### Populate the database python manage.py migrate diff --git a/amelie/activities/templates/gallery.html b/amelie/activities/templates/gallery.html index 1608a91..ac02e08 100644 --- a/amelie/activities/templates/gallery.html +++ b/amelie/activities/templates/gallery.html @@ -20,7 +20,7 @@

{% if login_for_more %}

- {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% trans "More photos are available if you" %} {% trans 'logged in' %}.

{% endif %} diff --git a/amelie/activities/templates/gallery_overview.html b/amelie/activities/templates/gallery_overview.html index 91b85e5..e2c091d 100644 --- a/amelie/activities/templates/gallery_overview.html +++ b/amelie/activities/templates/gallery_overview.html @@ -175,7 +175,7 @@

{% if not request.person %}

- {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% trans "More photos are available if you" %} {% trans 'logged in' %}.

diff --git a/amelie/activities/templates/includes/activity_enrollment.html b/amelie/activities/templates/includes/activity_enrollment.html index 473ca30..6d32532 100644 --- a/amelie/activities/templates/includes/activity_enrollment.html +++ b/amelie/activities/templates/includes/activity_enrollment.html @@ -134,7 +134,7 @@ {% endif %} {% else %}

- {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% blocktrans with login_url=login_url next_url=obj.get_absolute_url %} To view or alter your enrollment, please log in. {% endblocktrans %} diff --git a/amelie/api/urls.py b/amelie/api/urls.py index 0610160..02ded97 100644 --- a/amelie/api/urls.py +++ b/amelie/api/urls.py @@ -3,10 +3,13 @@ from modernrpc.core import Protocol from modernrpc.views import RPCEntryPoint +from amelie.companies.views import vivatbanner_get + app_name = 'api' urlpatterns = [ path('', RPCEntryPoint.as_view(protocol=Protocol.JSON_RPC), name="jsonrpc_mountpoint"), path('docs/', RPCEntryPoint.as_view(enable_doc=True, enable_rpc=False, template_name="api/doc_index.html")), + path('vivat_banners/', vivatbanner_get, name='vivatbanner_get'), ] diff --git a/amelie/claudia/account_views.py b/amelie/claudia/account_views.py index 7cc1972..5c58b39 100644 --- a/amelie/claudia/account_views.py +++ b/amelie/claudia/account_views.py @@ -247,7 +247,7 @@ def dispatch(self, request, *args, **kwargs): class AccountPasswordResetLink(FormView): template_name = "accounts/password_reset_link.html" form_class = AccountPasswordResetLinkForm - success_url = reverse_lazy('login') + success_url = reverse_lazy('oidc_authentication_init') def dispatch(self, request, *args, **kwargs): if self.request.user.is_authenticated: diff --git a/amelie/claudia/templates/accounts/password_reset_success.html b/amelie/claudia/templates/accounts/password_reset_success.html index 7dc0b06..9fbd868 100644 --- a/amelie/claudia/templates/accounts/password_reset_success.html +++ b/amelie/claudia/templates/accounts/password_reset_success.html @@ -10,7 +10,7 @@

{% trans "Reset e-mail sent" %}

{% trans 'Check the email address you have registered at Inter-Actief for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.' %}

- {% trans 'Return to login' %} + {% trans 'Return to login' %}
{% endblock content %} diff --git a/amelie/companies/admin.py b/amelie/companies/admin.py index 701da7e..4847656 100644 --- a/amelie/companies/admin.py +++ b/amelie/companies/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from amelie.companies.models import Company, TelevisionBanner, WebsiteBanner +from amelie.companies.models import Company, TelevisionBanner, WebsiteBanner, VivatBanner class CompanyAdmin(admin.ModelAdmin): @@ -16,6 +16,11 @@ class TelevisionBannerAdmin(admin.ModelAdmin): list_display = ['name', 'start_date', 'end_date', 'active'] +class VivatBannerAdmin(admin.ModelAdmin): + list_display = ['name', 'start_date', 'end_date', 'active'] + + admin.site.register(Company, CompanyAdmin) admin.site.register(WebsiteBanner, WebsiteBannerAdmin) admin.site.register(TelevisionBanner, TelevisionBannerAdmin) +admin.site.register(VivatBanner, VivatBannerAdmin) diff --git a/amelie/companies/forms.py b/amelie/companies/forms.py index 9bdf604..3979b6b 100644 --- a/amelie/companies/forms.py +++ b/amelie/companies/forms.py @@ -5,7 +5,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from amelie.companies.models import Company, WebsiteBanner, TelevisionBanner, CompanyEvent +from amelie.companies.models import Company, WebsiteBanner, TelevisionBanner, CompanyEvent, VivatBanner from amelie.style.forms import inject_style from amelie.calendar.forms import EventForm from amelie.tools.widgets import DateTimeSelector, DateSelector @@ -62,7 +62,7 @@ def clean_app_logo(self): class BannerForm(forms.ModelForm): - picture = forms.FileField(label=_('Website banner'), widget=AdminFileWidget) + picture = forms.ImageField(label=_('Website banner'), widget=AdminFileWidget) start_date = forms.DateField(widget=DateSelector) end_date = forms.DateField(widget=DateSelector) @@ -72,7 +72,7 @@ class Meta: class TelevisionBannerForm(forms.ModelForm): - picture = forms.FileField(label=_('Television banner'), widget=AdminFileWidget) + picture = forms.ImageField(label=_('Television banner'), widget=AdminFileWidget) start_date = forms.DateField(widget=DateSelector) end_date = forms.DateField(widget=DateSelector) @@ -81,6 +81,16 @@ class Meta: fields = ["picture", "name", 'start_date', "end_date", 'active'] +class VivatBannerForm(forms.ModelForm): + picture = forms.FileField(label=_('Vivat banner'), widget=AdminFileWidget) + start_date = forms.DateField(widget=DateSelector) + end_date = forms.DateField(widget=DateSelector) + + class Meta: + model = VivatBanner + fields = ["picture", "name", 'start_date', "end_date", 'active', 'url'] + + class CompanyEventForm(EventForm): company = forms.ModelChoiceField(queryset=Company.objects.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now()), required=False) @@ -130,4 +140,4 @@ class StatisticsForm(forms.Form): end_date = forms.SplitDateTimeField(label=_('End (till)'), widget=DateTimeSelector, required=True) -inject_style(CompanyForm, CompanyEventForm, BannerForm, TelevisionBannerForm, StatisticsForm) +inject_style(CompanyForm, CompanyEventForm, BannerForm, TelevisionBannerForm, VivatBannerForm, StatisticsForm) diff --git a/amelie/companies/migrations/0006_vivatbanner.py b/amelie/companies/migrations/0006_vivatbanner.py new file mode 100644 index 0000000..8012b9b --- /dev/null +++ b/amelie/companies/migrations/0006_vivatbanner.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.17 on 2023-05-23 14:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('companies', '0005_add_default_career_label'), + ] + + operations = [ + migrations.CreateModel( + name='VivatBanner', + fields=[ + ('basebanner_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='companies.basebanner')), + ('url', models.URLField()), + ('views', models.PositiveIntegerField(default=0, editable=False)), + ], + options={ + 'verbose_name': 'I/O Vivat banner', + 'verbose_name_plural': 'I/O Vivat banners', + 'ordering': ['-end_date'], + }, + bases=('companies.basebanner',), + ), + ] diff --git a/amelie/companies/models.py b/amelie/companies/models.py index aeef0c4..4942c8f 100644 --- a/amelie/companies/models.py +++ b/amelie/companies/models.py @@ -178,6 +178,20 @@ class Meta: verbose_name_plural = _('Television banners') +class VivatBanner(BaseBanner): + url = models.URLField() + views = models.PositiveIntegerField(editable=False, default=0) + + def save(self, **kwargs): + self.slug = slugify(self.name) + super(VivatBanner, self).save(**kwargs) + + class Meta: + ordering = ['-end_date'] + verbose_name = _('I/O Vivat banner') + verbose_name_plural = _('I/O Vivat banners') + + class CompanyEvent(Event): objects = EventManager() company = models.ForeignKey('Company', blank=True, null=True, on_delete=models.SET_NULL) diff --git a/amelie/companies/templates/companies/company_banners.html b/amelie/companies/templates/companies/company_banners.html index 0c3f92a..f627113 100644 --- a/amelie/companies/templates/companies/company_banners.html +++ b/amelie/companies/templates/companies/company_banners.html @@ -11,7 +11,7 @@

{% trans 'Website banners' %}

{% blocktrans %} - Click on the website banner to edit it + Click on the website banner to edit it. {% endblocktrans %}

{% trans 'New' %} @@ -47,8 +47,7 @@

{% trans "Television banners" %}

{% blocktrans %} - Clicking on the television banner should allow you to edit it, but this feature has not been - implemented yet. + Click on the television banner to edit it. {% endblocktrans %}

{% trans "New" %} @@ -76,4 +75,40 @@

{% trans "Television banners" %}

{% endfor %} + +
+
+

{% trans "I/O Vivat banners" %}

+ +
+

+ {% blocktrans %} + Click on the vivat banner to edit it. + {% endblocktrans %} +

+ {% trans "New" %} +
+
+
+ {% for vb in vivat_banners %} +
+ +
+ {% endfor %} {% endblock %} diff --git a/amelie/companies/urls.py b/amelie/companies/urls.py index 6966d0a..587a56c 100644 --- a/amelie/companies/urls.py +++ b/amelie/companies/urls.py @@ -11,6 +11,7 @@ path('banners//', views.banner_edit, name='banner_edit'), path('banners/create_web/', views.websitebanner_create, name='websitebanner_create'), path('banners/create_tv/', views.televisionbanner_create, name='televisionbanner_create'), + path('banners/create_vivat/', views.vivatbanner_create, name='vivatbanner_create'), path('activities/', views.event_list, name='event_list'), path('activities//', views.event_details, name='event_details'), diff --git a/amelie/companies/views.py b/amelie/companies/views.py index 38ace3b..2469741 100644 --- a/amelie/companies/views.py +++ b/amelie/companies/views.py @@ -2,6 +2,7 @@ import logging import random +from django.conf import settings from django.urls import reverse from django.db.models import Q from django.http import HttpResponseRedirect, HttpResponse @@ -10,13 +11,15 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView -from amelie.companies.forms import CompanyForm, BannerForm, TelevisionBannerForm, CompanyEventForm, StatisticsForm -from amelie.companies.models import BaseBanner, Company, CompanyEvent, TelevisionBanner, WebsiteBanner +from amelie.companies.forms import CompanyForm, BannerForm, TelevisionBannerForm, CompanyEventForm, StatisticsForm, \ + VivatBannerForm +from amelie.companies.models import BaseBanner, Company, CompanyEvent, TelevisionBanner, WebsiteBanner, VivatBanner from amelie.members.models import Committee from amelie.statistics.decorators import track_hits from amelie.tools.decorators import require_board from amelie.tools.forms import PeriodForm from amelie.tools.calendar import ical_calendar +from amelie.tools.http import HttpJSONResponse from amelie.tools.mixins import RequireBoardMixin, RequireCommitteeMixin logger = logging.getLogger(__name__) @@ -58,19 +61,25 @@ def banner_list(request): """ List of all banners """ website_banners = WebsiteBanner.objects.all() television_banners = TelevisionBanner.objects.all() + vivat_banners = VivatBanner.objects.all() today = datetime.date.today() - return render(request, 'companies/company_banners.html', {'website_banners': website_banners, 'television_banners': television_banners, 'today': today}) + return render(request, 'companies/company_banners.html', { + 'website_banners': website_banners, 'television_banners': television_banners, 'vivat_banners': vivat_banners, + 'today': today + }) @require_board def banner_edit(request, id): - """ Edit either a website banner or television banner. """ + """ Edit either a website banner, television banner or vivat banner. """ obj = get_object_or_404(BaseBanner, id=id) form = None if request.method == "POST": if hasattr(obj, 'websitebanner'): form = BannerForm(request.POST, request.FILES, instance=obj.websitebanner) + elif hasattr(obj, 'vivatbanner'): + form = VivatBannerForm(request.POST, request.FILES, instance=obj.vivatbanner) else: form = TelevisionBannerForm(request.POST, request.FILES, instance=obj.televisionbanner) @@ -81,6 +90,8 @@ def banner_edit(request, id): else: if hasattr(obj, 'websitebanner'): form = BannerForm(instance=obj.websitebanner) + elif hasattr(obj, 'vivatbanner'): + form = VivatBannerForm(instance=obj.vivatbanner) else: form = TelevisionBannerForm(instance=obj.televisionbanner) @@ -117,6 +128,51 @@ def televisionbanner_create(request): return render(request, 'companies/company_banners_form.html', locals()) +@require_board +def vivatbanner_create(request): + """ Create a vivat banner. """ + if request.method == "POST": + form = VivatBannerForm(request.POST, request.FILES) + if form.is_valid(): + form.save() + return HttpResponseRedirect(reverse('companies:banner_list')) + + else: + form = VivatBannerForm() + + return render(request, 'companies/company_banners_form.html', locals()) + + +def vivatbanner_get(request): + """ Gets a number (3 default) of vivat banners in JSON format for use on the IO Vivat website. """ + try: + # Get amount from argument 'n', default is 3 + amount = int(request.GET.get("n", "3")) + # Clamp amount between 1 and 20 + amount = max(1, min(amount, 20)) + except ValueError: + # Could not parse argument, use default + amount = 3 + + # Get banners (only currently active banners, random order) + banners = VivatBanner.objects.filter( + active=True, start_date__lte=datetime.date.today(), end_date__gte=datetime.date.today() + ).order_by('?')[:amount] + + # Return them as JSON data + return HttpJSONResponse({ + 'amount': amount, + 'banners': [ + { + "picture": "{}{}".format(settings.MEDIA_URL, banner.picture), + "name": banner.name, + "link": banner.url + } + for banner in banners + ] + }) + + @require_board def company_overview_old(request): """ diff --git a/amelie/data_export/exporters/amelie.py b/amelie/data_export/exporters/amelie.py index bc67322..aeae64e 100644 --- a/amelie/data_export/exporters/amelie.py +++ b/amelie/data_export/exporters/amelie.py @@ -5,8 +5,6 @@ from oauth2_provider.models import Application, AccessToken, Grant from zipfile import ZipFile -from social_django.models import UserSocialAuth - from amelie.claudia.models import Mapping, Timeline, Event as ClaudiaEvent from amelie.data_export.exporters.exporter import DataExporter from amelie.personal_tab.models import ReversalTransaction, DebtCollectionTransaction, CustomTransaction, \ @@ -310,11 +308,6 @@ def export_oauth(self): 'expiry_date': str(grant.expires), 'scopes': str(grant.scope), } for grant in Grant.objects.filter(user=person.user)], - 'authentication_providers': [{ - 'provider': auth.provider, - 'uid': auth.uid, - 'extra_data': auth.extra_data, - } for auth in UserSocialAuth.objects.filter(user=person.user)], } return oauth_data, [] diff --git a/amelie/gmm/templates/gmm_overview.html b/amelie/gmm/templates/gmm_overview.html index e81357e..f74155d 100644 --- a/amelie/gmm/templates/gmm_overview.html +++ b/amelie/gmm/templates/gmm_overview.html @@ -48,8 +48,8 @@

{% trans 'Articles of Association, Rules and Regulations and other documents English)
  • - Declaration Policy, last modified on the GMM of 1 February 2021 (Dutch / - English) + Declaration Policy, last modified on the GMM of 20 March 2023 (Dutch / + English)
  • Explainer "What is a GMM?" (English) diff --git a/amelie/members/forms.py b/amelie/members/forms.py index b92c3d1..4648133 100644 --- a/amelie/members/forms.py +++ b/amelie/members/forms.py @@ -44,13 +44,16 @@ def save(self, *args, **kwargs): old_person = Person.objects.get(id=self.instance.id) for field in self.changed_data: if field == "preferences": + old = [preference.name for preference in getattr(old_person, field).all()] + new = [preference.name for preference in self.cleaned_data[field].all()] + [preference.name for preference in self.instance.preferences.filter(adjustable=False)] + + added = list(set(new)-set(old)) + removed = list(set(old)-set(new)) + changes.append({ 'field': 'Preferences', - 'old': ', '.join([preference.name for preference in getattr(old_person, field).all()]), - 'new': ', '.join( - [preference.name for preference in self.cleaned_data[field].all()] + - [preference.name for preference in self.instance.preferences.filter(adjustable=False)] - ) + 'added': ' + ' + '\n + '.join(added) if added else '', + 'removed': ' - ' + '\n - '.join(removed) if removed else '' }) else: changes.append({ diff --git a/amelie/members/templates/members/profile_changed.mail b/amelie/members/templates/members/profile_changed.mail index 3c788b9..7ec1ca0 100644 --- a/amelie/members/templates/members/profile_changed.mail +++ b/amelie/members/templates/members/profile_changed.mail @@ -7,7 +7,15 @@ {% blocktrans %}{{ obj }} edited his/her profile in Amélie.{% endblocktrans %} {% for c in changes %} - * {{ c.field }}: {{ c.old }} --> {{ c.new }}{% endfor %} +{% if c.field == "Preferences" %} + * {{ c.field }}: +{{ c.added }} +{{ c.removed }} +{% else %} + * {{ c.field }}: {{ c.old }} --> {{ c.new }} +{% endif%} + +{% endfor %} {% blocktrans %}For more information, see:{% endblocktrans %} {% onlyhtml %}{% endonlyhtml %}https://www.inter-actief.utwente.nl{{ url }}{% onlyhtml %}{% endonlyhtml %} diff --git a/amelie/members/views.py b/amelie/members/views.py index a7d96d6..77f48f4 100644 --- a/amelie/members/views.py +++ b/amelie/members/views.py @@ -19,7 +19,6 @@ from fcm_django.models import FCMDevice from formtools.wizard.views import SessionWizardView from oauth2_provider.models import AccessToken, Grant -from social_django.models import UserSocialAuth from wsgiref.util import FileWrapper @@ -31,7 +30,7 @@ from django.utils.translation import gettext as _ from django.views.generic.edit import DeleteView, FormView -from amelie.claudia.models import Mapping +from amelie.claudia.models import Mapping, ExtraPerson from amelie.members.forms import PersonDataForm, StudentNumberForm, \ RegistrationFormPersonalDetails, RegistrationFormStepMemberContactDetails, \ RegistrationFormStepParentsContactDetails, RegistrationFormStepFreshmenStudyDetails, \ @@ -334,7 +333,6 @@ def person_anonymize(request, id, slug): if hasattr(person, 'user'): AccessToken.objects.filter(user=person.user).delete() Grant.objects.filter(user=person.user).delete() - UserSocialAuth.objects.filter(user=person.user).delete() FCMDevice.objects.filter(user=person.user).delete() # Anonymize education @@ -1352,6 +1350,16 @@ def _person_info_request_get_body(request, logger=None): logger.error(f"Permission denied, provided API key is incorrect.") raise PermissionDenied() + # If given, the 'verify' key should be a boolean value + if 'verify' in body and not isinstance(body['verify'], bool): + logger.error("Bad request, verify key was given but was not a boolean value.") + raise BadRequest() + + # If given, the 'departments' key should be a comma separated string value + if 'departments' in body and not isinstance(body['departments'], str): + logger.error("Bad request, departments key was given but was not a comma separated string value.") + raise BadRequest() + return body @@ -1361,11 +1369,16 @@ def _get_ttl_hash(seconds=3600): # noinspection PyUnusedLocal @lru_cache() -def _person_info_get_person(ia_username=None, ut_username=None, local_username=None, verify=False, +def _person_info_get_person(ia_username=None, ut_username=None, local_username=None, verify=False, departments=None, ttl_hash=None, logger=None): if logger is None: logger = logging.getLogger("amelie.members.views._person_info_get_person") + if departments is None: + departments = [] + else: + departments = departments.split(",") + del ttl_hash # ttl_hash is just to get the lru_cache decorator to keep results for only 1 hour person = None if ia_username is not None: @@ -1373,17 +1386,38 @@ def _person_info_get_person(ia_username=None, ut_username=None, local_username=N person = Person.objects.get(account_name=ia_username) logger.debug(f"Person found by ia_username {ia_username}: {person}.") except Person.DoesNotExist: - logger.info(f"Person with ia_username {ia_username} does not exist.") - person = None + # It could also be an ExtraPerson + try: + person = ExtraPerson.objects.get(adname=ia_username) + logger.debug(f"ExtraPerson found by ia_username {ia_username}: {person}.") + if not person.is_active(): + person = None + logger.info(f"Account of ExtraPerson {person} is not active (any more), " + f"and thus is no info will be returned for username {ia_username}.") + except ExtraPerson.DoesNotExist: + logger.info(f"Person or ExtraPerson with ia_username {ia_username} does not exist.") + person = None elif ut_username is not None: try: if ut_username[0] == 's': person = Person.objects.get(student__number=ut_username[1:]) logger.debug(f"Person found by student ut_username {ut_username}: {person}.") + if verify: - # TODO: Verify studies of person if department data is present in auth request. - logger.debug(f"Verifying study for {person} (unimplemented).") - pass + # Verify studies of person if department data is present in auth request. + logger.debug(f"Verifying study for {person}.") + + # Since UT years are from sept-aug and our year is from jul-jun, there are two months overlap. + # Never verify users in July or August, as this could allow members who are done studying to get an + # extra year as study long. + if not person.membership.is_verified() and datetime.date.today().month not in [7, 8]: + primary_studies = Study.objects.filter(primary_study=True).values_list('abbreviation', + flat=True) + for study in departments: + if study in primary_studies: + person.membership.verify() + break + elif ut_username[0] == 'm': person = Person.objects.get(employee__number=ut_username[1:]) logger.debug(f"Person found by employee ut_username {ut_username}: {person}.") @@ -1410,6 +1444,10 @@ def _person_info_get_person(ia_username=None, ut_username=None, local_username=N else: logger.info(f"Cannot find person with invalid local_username {local_username}.") person = None + else: + logger.warning(f"Person lookup requested but no IA, UT or local username given?! - " + f"Args: ia_username={ia_username}, ut_username={ut_username}, local_username={local_username}, " + f"verify={verify}, departments={departments}.") return person @@ -1434,14 +1472,25 @@ def person_userinfo(request): username = body.get('iaUsername', None) or body.get('utUsername', None) or body.get('localUsername', None) # If a person was found, return the userinfo that auth.ia needs. Else return an empty object. - if person is not None: + if person is not None and isinstance(person, Person): log.info(f"UserInfo retrieved for person {person} using username {username}.") + email = "{}@{}".format(person.get_adname(), settings.CLAUDIA_GSUITE['PRIMARY_DOMAIN']) return HttpJSONResponse({ - 'iaUsername': person.account_name or None, + 'iaUsername': person.get_adname() or None, + 'iaEmail': email if person.get_adname() else None, 'studentNumber': f"s{person.student.number}" if person.is_student() else None, 'employeeNumber': f"m{person.employee.number}" if person.is_employee() else None, 'externalUsername': person.ut_external_username }) + elif person is not None and isinstance(person, ExtraPerson): + log.info(f"UserInfo retrieved for ExtraPerson {person} using username {username}.") + return HttpJSONResponse({ + 'iaUsername': person.get_adname() or None, + 'iaEmail': person.get_email() or None, + 'studentNumber': None, + 'employeeNumber': None, + 'externalUsername': None + }) else: log.info(f"UserInfo not found for user iaUsername={body.get('iaUsername', None)}, " f"utUsername={body.get('utUsername', None)}, localUsername={body.get('localUsername', None)}.") @@ -1454,8 +1503,9 @@ def person_groupinfo(request): Retrieves group info about a person based on either their active member username or UT s- or m-number. Used by our authentication platform to determine permissions for external users (UT or social login). - This also verifies studies, because the UT department info will be passed to this endpoint in the body if - it was received by the authentication platform. This endpoint is called when a user logs in with their UT account. + This also verifies studies if requested, because the UT department info will be passed to this endpoint in the + body if it was received by the authentication platform. This endpoint is called when a user logs in with an + external account (UT, Google, GitHub, etc.). """ log = logging.getLogger("amelie.members.views.person_groupinfo") # Verify request and get the JSON body @@ -1464,7 +1514,8 @@ def person_groupinfo(request): # Access verified. Find the Person associated with the provided username. person = _person_info_get_person( ia_username=body.get('iaUsername', None), ut_username=body.get('utUsername', None), - local_username=body.get('localUsername', None), verify=True, ttl_hash=_get_ttl_hash(), logger=log + local_username=body.get('localUsername', None), verify=body.get('verify', False), + departments=body.get('departments', None), ttl_hash=_get_ttl_hash(), logger=log ) username = body.get('iaUsername', None) or body.get('utUsername', None) or body.get('localUsername', None) @@ -1480,7 +1531,8 @@ def person_groupinfo(request): else: log.info(f"GroupInfo found no groups for username {username} - User has no mapping.") return HttpJSONResponse({"groups": []}) - log.info(f"GroupInfo not found for username {username}.") + log.info(f"GroupInfo not found for username(s) ia={body.get('iaUsername', None)} " + f"ut={body.get('utUsername', None)} local={body.get('localUsername', None)}.") return HttpJSONResponse({}) diff --git a/amelie/oauth/admin.py b/amelie/oauth/admin.py deleted file mode 100644 index 8e1ed13..0000000 --- a/amelie/oauth/admin.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.contrib import admin - -from amelie.oauth.models import LoginToken - -admin.site.register(LoginToken) diff --git a/amelie/oauth/management/__init__.py b/amelie/oauth/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/amelie/oauth/management/commands/__init__.py b/amelie/oauth/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/amelie/oauth/management/commands/clean_oauth.py b/amelie/oauth/management/commands/clean_oauth.py deleted file mode 100644 index c335d9b..0000000 --- a/amelie/oauth/management/commands/clean_oauth.py +++ /dev/null @@ -1,20 +0,0 @@ -from datetime import timedelta - -from django.core.management import BaseCommand -from django.utils import timezone -from social_django.models import Partial - -from amelie.oauth.models import LoginToken - - -class Command(BaseCommand): - EXPIRE_PARTIAL_AFTER = timedelta(hours=24) - - help = 'Delete login tokens and partial pipelines that are older than 24 hours' - - def handle(self, *args, **options): - deadline_token = timezone.now() - LoginToken.EXPIRE_AFTER - LoginToken.objects.filter(date__lt=deadline_token).delete() - - deadline_partial = timezone.now() - Command.EXPIRE_PARTIAL_AFTER - Partial.objects.filter(timestamp__lt=deadline_partial).delete() \ No newline at end of file diff --git a/amelie/oauth/migrations/0003_delete_logintoken.py b/amelie/oauth/migrations/0003_delete_logintoken.py new file mode 100644 index 0000000..c230b84 --- /dev/null +++ b/amelie/oauth/migrations/0003_delete_logintoken.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.18 on 2023-05-01 17:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth', '0002_auto_20201103_2220'), + ] + + operations = [ + migrations.DeleteModel( + name='LoginToken', + ), + ] diff --git a/amelie/oauth/models.py b/amelie/oauth/models.py index 8bba19c..e69de29 100644 --- a/amelie/oauth/models.py +++ b/amelie/oauth/models.py @@ -1,24 +0,0 @@ -import uuid -from datetime import timedelta - -from django.db import models -from django.urls import reverse - -from amelie.members.models import Person - - -class LoginToken(models.Model): - EXPIRE_AFTER = timedelta(hours=24) - - person = models.ForeignKey(Person, on_delete=models.CASCADE) - token = models.CharField(max_length=255) - date = models.DateTimeField(auto_now_add=True) - - def save(self, *args, **kwargs): - if not self.token: - self.token = uuid.uuid4().hex - - super().save(*args, **kwargs) - - def get_absolute_url(self): - return reverse('oauth:token_login', kwargs={"token": self.token}) diff --git a/amelie/oauth/pipeline.py b/amelie/oauth/pipeline.py deleted file mode 100644 index 7a04cfc..0000000 --- a/amelie/oauth/pipeline.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.contrib import messages -from django.urls import reverse -from django.utils.http import urlencode -from social_core.pipeline.partial import partial -from django.utils.translation import gettext_lazy as _ - -from amelie.oauth.templatetags.provider_name import provider_name - - -@partial -def require_login_for_association(strategy, current_partial, user=None, *args, **kwargs): - if user is not None: - return - - # Instruct the login view not to offer an oauth login option. - # Set the next link to resume the social_oauth pipeline, associating the user. - continue_url = reverse('social_auth:complete', kwargs={"backend": current_partial.backend}) - - query = { - 'no_oauth': '1', - 'next': continue_url, - } - - return strategy.redirect(reverse('login') + '?' + urlencode(query)) - - -def message_new_association(request, backend=None, new_association=False, *args, **kwargs): - if new_association: - message = _("Login provider %(provider)s added successfully") % {'provider': provider_name(backend)} - messages.success(request, message) - - -def message_remove_association(strategy=None, backend=None, *args, **kwargs): - message = _("Login provider %(provider)s has been deleted") % {'provider': provider_name(backend)} - messages.success(strategy.request, message) diff --git a/amelie/oauth/templates/send_token.mail b/amelie/oauth/templates/send_token.mail deleted file mode 100644 index 2aacfb9..0000000 --- a/amelie/oauth/templates/send_token.mail +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "iamailer/email_basic.mail" %} -{% load htmlify i18n only %} - -{% block subject %}[Inter-Actief] {% blocktrans %}Connect external account{% endblocktrans %}{% endblock %} - -{% block content %}{% htmlify %}{% blocktrans %}Dear{% endblocktrans %} {{ recipient.first_name }}, - -{% blocktrans %}You receive this e-mail because you can no longer login on the website of{% endblocktrans %} -Inter-{% onlyhtml %}{% endonlyhtml %}Actief{% onlyhtml %}{% endonlyhtml %}. {% blocktrans %}This is because you are not/no longer a student or employee of the University of Twente. - -You can connect an external account through the following link:{% endblocktrans %} -{% onlyhtml %}{% endonlyhtml %}{{url}}{% onlyhtml %}{% endonlyhtml %} - -{% blocktrans %}Best of luck! - -Kind regards,{% endblocktrans %} -Inter-{% onlyhtml %}{% endonlyhtml %}Actief{% onlyhtml %}{% endonlyhtml %}{% endhtmlify %}{% endblock %} diff --git a/amelie/oauth/templates/token_login.html b/amelie/oauth/templates/token_login.html deleted file mode 100644 index 91d4530..0000000 --- a/amelie/oauth/templates/token_login.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends 'basis.html' %} -{% load i18n provider_name %} - -{% block titel %} - {% trans 'Log in' %} -{% endblock %} - -{% block content %} -
    -
    -

    {% trans 'Login on the Inter-Actief website' %}

    -
    - {% blocktrans %} - You are here because yo do not have a way to log in to the Inter-Actief website (anymore). - While you have been logged in now, it is wise to add an alternative login method below: - {% endblocktrans %} - - -
    -
    -
    -{% endblock %} diff --git a/amelie/oauth/templatetags/__init__.py b/amelie/oauth/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/amelie/oauth/templatetags/provider_name.py b/amelie/oauth/templatetags/provider_name.py deleted file mode 100644 index 234a6bb..0000000 --- a/amelie/oauth/templatetags/provider_name.py +++ /dev/null @@ -1,22 +0,0 @@ -from django import template - -register = template.Library() - - -@register.filter -def provider_name(provider): - providers = { - 'google-oauth2': 'Google', - 'github': 'GitHub', - 'linkedin-oauth2': 'LinkedIn', - 'facebook': 'Facebook' - } - - if not isinstance(provider, str): - if hasattr(provider, 'title'): - provider = provider.title - - if hasattr(provider, 'name'): - provider = provider.name - - return providers.get(provider.lower(), None) diff --git a/amelie/personal_tab/pos_views.py b/amelie/personal_tab/pos_views.py index 0ec5aa9..9b4d367 100644 --- a/amelie/personal_tab/pos_views.py +++ b/amelie/personal_tab/pos_views.py @@ -242,7 +242,7 @@ def render_to_response(self, context, **response_kwargs): # If no user is logged in, redirect to login and then back here. if not self.request.user.is_authenticated: redirect_url = reverse('personal_tab:pos_verify', kwargs={'uuid': uuid}) - login_url_with_redirect = reverse('login') + "?next={}".format(redirect_url) + login_url_with_redirect = reverse('oidc_authentication_init') + "?next={}".format(redirect_url) return redirect(login_url_with_redirect) pending_login = None diff --git a/amelie/publications/templates/publications/publications.html b/amelie/publications/templates/publications/publications.html index bcd85b7..6cb72b8 100644 --- a/amelie/publications/templates/publications/publications.html +++ b/amelie/publications/templates/publications/publications.html @@ -26,7 +26,7 @@

    {% if login_voor_meer %}

    - {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% trans "More publications are available if you" %} {% trans 'logged in.' %}.

    {% endif %} diff --git a/amelie/settings/generic.py b/amelie/settings/generic.py index e52366f..c597106 100644 --- a/amelie/settings/generic.py +++ b/amelie/settings/generic.py @@ -79,13 +79,6 @@ # The LDAP host that is used to verify login attempts in the LDAP authentication module LDAP_HOST = 'hexia.ia.utwente.nl' -# The RADIUS login details used to verify login attempts in the RADIUS authentication module -RADIUS_HOST = 'radius1.utsp.utwente.nl' -RADIUS_PORT = 1645 -RADIUS_SECRET = b'etisbew_ai_www' -RADIUS_IDENTIFIER = 'interactief.utwente.nl' -RADIUS_DICT_LOCATION = os.path.join(BASE_PATH, 'amelie', 'tools', 'radius.dict') - # Caches that the website can use CACHES = { 'default': { @@ -187,8 +180,6 @@ 'amelie.style.context_processors.theme_context', # Injects the website theme if one is active 'amelie.api.context_processors.absolute_path_to_site', # Injects the absolute path to the site for API 'amelie.tools.context_processors.environment', # Injects environment context - 'social_django.context_processors.backends', - 'social_django.context_processors.login_redirect', ], 'loaders': [ 'django.template.loaders.filesystem.Loader', @@ -948,3 +939,4 @@ KEYCLOAK_API_CLIENT_ID = "admin-cli" KEYCLOAK_API_CLIENT_SECRET = "" KEYCLOAK_API_AUTHN_ENDPOINT = "https://auth.ia.utwente.nl/realms/inter-actief/protocol/openid-connect/token" +KEYCLOAK_PROVIDERS_UNLINK_ALLOWED = ['github', 'google', 'linkedin'] diff --git a/amelie/style/static/robots.txt b/amelie/style/static/robots.txt index eab2bb7..782140d 100644 --- a/amelie/style/static/robots.txt +++ b/amelie/style/static/robots.txt @@ -2,8 +2,8 @@ User-agent: * Disallow: /admin/ Disallow: /leden/ Disallow: /members/ -Disallow: /login/ -Disallow: /logout/ +Disallow: /legacy_login/ +Disallow: /legacy_logout/ Disallow: /autosearch/ Disallow: /gmm/ Disallow: /profile/ @@ -18,8 +18,8 @@ User-agent: Googlebot Disallow: /admin/ Disallow: /leden/ Disallow: /members/ -Disallow: /login/ -Disallow: /logout/ +Disallow: /legacy_login/ +Disallow: /legacy_logout/ Disallow: /autosearch/ Disallow: /gmm/ Disallow: /profile/ diff --git a/amelie/test_all_urls.py b/amelie/test_all_urls.py index 7db2738..0d60cef 100644 --- a/amelie/test_all_urls.py +++ b/amelie/test_all_urls.py @@ -37,8 +37,8 @@ "account:activate_forwarding_address", "account:add_forwarding_address", "account:check_forwarding_status", "account:check_forwarding_verification", "account:deactivate_forwarding_address", - # Uses YouTube API credentials, does not need to work in development - "videos:new_yt_video", + # Uses YouTube API credentials / relies on external service, does not need to work in development + "videos:new_yt_video", "videos:new_ia_video", # Room narrowcasting page uses Spotify and Icinga API that is not configured in development. "narrowcasting:room_pcstatus", "narrowcasting:room_spotify_callback", "narrowcasting:room_spotify_now_playing", diff --git a/amelie/tools/auth.py b/amelie/tools/auth.py index 4105fe4..68e427b 100644 --- a/amelie/tools/auth.py +++ b/amelie/tools/auth.py @@ -14,6 +14,7 @@ from amelie.iamailer import MailTask from amelie.members.models import Person +from amelie.tools.keycloak import KeycloakAPI from amelie.tools.mail import PersonRecipient @@ -144,54 +145,39 @@ def filter_users_by_claims(self, claims): def get_oauth_link_code(person): - # Login to keycloak API - response = requests.post( - f"{settings.KEYCLOAK_API_AUTHN_ENDPOINT}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={"grant_type": "client_credentials", "client_id": settings.KEYCLOAK_API_CLIENT_ID, "client_secret": settings.KEYCLOAK_API_CLIENT_SECRET} - ) - access_token = response.json()['access_token'] + # Get keycloak API + kc = KeycloakAPI() # See if a Keycloak user already exists - response = requests.get( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users?username=ia{person.pk}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - users = response.json() - user = users[0] if len(users) > 0 else None + users = kc.get_user_details_by_username(username=f"ia{person.pk}") + user = users[0] if users and len(users) > 0 else None three_days_from_now = round(time.mktime((datetime.datetime.now() + datetime.timedelta(days=3)).timetuple())) link_code = str(uuid.uuid4()) if user is None: # Create user last_name = f"{person.last_name_prefix} {person.last_name}" if person.last_name_prefix else person.last_name - response = requests.post( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users", - headers={"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}, - data=json.dumps({ - "username": f"ia{person.pk}", "firstName": person.first_name, "lastName": last_name, - "emailVerified": True, "enabled": True, "attributes": { - "created_by": "amelie", "localUsername": f"ia{person.pk}", "link_code": link_code, - "link_code_valid": f"{three_days_from_now}" - } - }) - ) - if response.status_code != 201: # 201 is user created - raise ValueError(f"Error creating KeyCloak user ia{person.pk} - {response.status_code} {response.content}") + + try: + kc.create_user(username=f"ia{person.pk}", first_name=person.first_name, last_name=last_name, + attributes={ + "created_by": "amelie", "localUsername": f"ia{person.pk}", + "link_code": link_code, + "link_code_valid": f"{three_days_from_now}" + }) + except ConnectionError as e: + raise ConnectionError(f"Error creating KeyCloak user ia{person.pk} - {e}") else: # Generate new link code for user new_attributes = user['attributes'] new_attributes["link_code"] = link_code new_attributes["link_code_valid"] = f"{three_days_from_now}" - response = requests.put( - f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/users/{user['id']}", - headers={"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}, - data=json.dumps({ + try: + kc.update_user(user_id=user['id'], data={ "attributes": new_attributes }) - ) - if response.status_code != 204: # 204 is no content (user updated successfully) - raise ValueError(f"Error updating KeyCloak user ia{person.pk} - {response.status_code} {response.content}") + except ConnectionError as e: + raise ConnectionError(f"Error updating KeyCloak user ia{person.pk} - {e}") return link_code @@ -204,3 +190,39 @@ def send_oauth_link_code_email(request, person, link_code): context={"link_code": link_code} )) task.send() + + +def get_user_info(person): + # Get keycloak API + kc = KeycloakAPI() + + # Find all users associated with the current user + all_users = [] + possible_usernames = [f"ia{person.pk}"] + if person.account_name: + possible_usernames.append(person.account_name) + if person.is_student() and person.student.student_number(): + possible_usernames.append(person.student.student_number()) + if person.is_employee() and person.employee.employee_number(): + possible_usernames.append(person.employee.employee_number()) + if person.ut_external_username: + possible_usernames.append(person.ut_external_username) + + for username in possible_usernames: + users = kc.get_brief_user_details_by_username(username=username) + if users and len(users) > 0: + user_data = kc.get_user_details_by_user_id(user_id=users[0]['id']) + user_data['credentials'] = kc.get_user_credentials_by_user_id(user_id=users[0]['id']) + all_users.append(user_data) + + return all_users + + +def unlink_totp(user_id, totp_id): + # Delete TOTP in Keycloak + KeycloakAPI().delete_credential(user_id=user_id, credential_id=totp_id) + + +def unlink_acount(user_id, provider_name): + # Delete TOTP in Keycloak + KeycloakAPI().delete_federated_identity(user_id=user_id, provider_name=provider_name) diff --git a/amelie/tools/keycloak.py b/amelie/tools/keycloak.py new file mode 100644 index 0000000..8b64f1c --- /dev/null +++ b/amelie/tools/keycloak.py @@ -0,0 +1,168 @@ +import logging +import requests +import json + +from django.conf import settings + + +class KeycloakAPI: + def __init__(self): + self.authn_endpoint = settings.KEYCLOAK_API_AUTHN_ENDPOINT + self.client_id = settings.KEYCLOAK_API_CLIENT_ID + self.client_secret = settings.KEYCLOAK_API_CLIENT_SECRET + self._access_token = None + self.log = logging.getLogger(self.__class__.__name__) + + def login(self): + response = requests.post( + f"{self.authn_endpoint}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={"grant_type": "client_credentials", "client_id": self.client_id, + "client_secret": self.client_secret} + ) + try: + self._access_token = response.json()['access_token'] + except (json.JSONDecodeError, KeyError) as e: + self.log.exception(f"Could not login to Keycloak API - " + f"status={response.status_code} content={response.content}") + raise ConnectionError(f"Could not login to Keycloak API, see logs for details - " + f"status={response.status_code}") + + @staticmethod + def _get_content(response): + if response.content: + try: + return response.json() + except json.JSONDecodeError: + return response.content + else: + return None + + def get(self, path, success_codes=None): + if self._access_token is None: + self.login() + + if success_codes is None: + success_codes = [200, 201, 204] + + response = requests.get( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/{path}", + headers={"Authorization": f"Bearer {self._access_token}"}, + ) + if response.status_code in success_codes: + return self._get_content(response) + else: + self.log.exception(f"Error calling GET on Keycloak API\n" + f"status={response.status_code} path={path}\n" + f"content={response.content}") + raise ConnectionError(f"Error calling GET on Keycloak API, see logs for details - " + f"status={response.status_code} path={path}") + + def post(self, path, data=None, success_codes=None): + if self._access_token is None: + self.login() + + if data is None: + data = {} + + if success_codes is None: + success_codes = [200, 201, 204] + + response = requests.post( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/{path}", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}" + }, + data=json.dumps(data) + ) + if response.status_code in success_codes: + return self._get_content(response) + else: + self.log.exception(f"Error calling POST on Keycloak API\n" + f"status={response.status_code} path={path}\n" + f"content={response.content}") + raise ConnectionError(f"Error calling POST on Keycloak API, see logs for details - " + f"status={response.status_code} path={path}") + + def put(self, path, data=None, success_codes=None): + if self._access_token is None: + self.login() + + if data is None: + data = {} + + if success_codes is None: + success_codes = [200, 201, 204] + + response = requests.put( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/{path}", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self._access_token}" + }, + data=json.dumps(data) + ) + if response.status_code in success_codes: + return self._get_content(response) + else: + self.log.exception(f"Error calling PUT on Keycloak API\n" + f"status={response.status_code} path={path}\n" + f"content={response.content}") + raise ConnectionError(f"Error calling PUT on Keycloak API, see logs for details - " + f"status={response.status_code} path={path}") + + def delete(self, path, success_codes=None): + if self._access_token is None: + self.login() + + if success_codes is None: + success_codes = [200, 201, 204] + + response = requests.delete( + f"{settings.KEYCLOAK_API_BASE}/{settings.KEYCLOAK_REALM_NAME}/{path}", + headers={"Authorization": f"Bearer {self._access_token}"}, + ) + if response.status_code in success_codes: + return self._get_content(response) + else: + self.log.exception(f"Error calling DELETE on Keycloak API\n" + f"status={response.status_code} path={path}\n" + f"content={response.content}") + raise ConnectionError(f"Error calling DELETE on Keycloak API, see logs for details - " + f"status={response.status_code} path={path}") + + def get_user_details_by_username(self, username): + return self.get(f"users?exact=true&username={username}") + + def create_user(self, username, first_name, last_name, attributes=None): + if attributes is None: + attributes = {} + data = { + "username": username, "firstName": first_name, "lastName": last_name, + "emailVerified": True, "enabled": True, "attributes": attributes + } + return self.post("users", data=data) + + def update_user(self, user_id, data=None): + if data is None: + data = {} + return self.put(f"users/{user_id}", data=data) + + def get_brief_user_details_by_username(self, username): + return self.get(f"users?exact=true&briefRepresentation=true&username={username}") + + def get_user_details_by_user_id(self, user_id): + return self.get(f"users/{user_id}") + + def get_user_credentials_by_user_id(self, user_id): + return self.get(f"users/{user_id}/credentials") + + def delete_credential(self, user_id, credential_id): + return self.delete(f"users/{user_id}/credentials/{credential_id}") + + def delete_federated_identity(self, user_id, provider_name): + if provider_name in settings.KEYCLOAK_PROVIDERS_UNLINK_ALLOWED: + return self.delete(f"users/{user_id}/federated-identity/{provider_name}") + else: + raise PermissionError(f"Cannot unlink {provider_name} via the website, contact the System Administrators.") diff --git a/amelie/tools/management/commands/www_obfuscate.py b/amelie/tools/management/commands/www_obfuscate.py index 4492a94..b4bfa0d 100644 --- a/amelie/tools/management/commands/www_obfuscate.py +++ b/amelie/tools/management/commands/www_obfuscate.py @@ -5,7 +5,6 @@ from django.contrib.sessions.models import Session from fcm_django.models import FCMDevice from django.contrib.auth.models import User -from social_django.models import UserSocialAuth, Association, Code, Nonce, Partial from django.utils import timezone from amelie.claudia.models import Timeline @@ -102,13 +101,6 @@ def handle(self, *args, **options): # Delete audits LogEntry.objects.all().delete() - # Delete all oauth authorizations - Association.objects.all().delete() - Code.objects.all().delete() - Nonce.objects.all().delete() - Partial.objects.all().delete() - UserSocialAuth.objects.all().delete() - # Delete Claudia Timeline Timeline.objects.all().delete() diff --git a/amelie/tools/radius.dict b/amelie/tools/radius.dict deleted file mode 100644 index 492565e..0000000 --- a/amelie/tools/radius.dict +++ /dev/null @@ -1,404 +0,0 @@ -# -# Version $Id: dictionary,v 1.1.1.1 2002/10/11 12:25:39 wichert Exp $ -# -# This file contains dictionary translations for parsing -# requests and generating responses. All transactions are -# composed of Attribute/Value Pairs. The value of each attribute -# is specified as one of 4 data types. Valid data types are: -# -# string - 0-253 octets -# ipaddr - 4 octets in network byte order -# integer - 32 bit value in big endian order (high byte first) -# date - 32 bit value in big endian order - seconds since -# 00:00:00 GMT, Jan. 1, 1970 -# -# FreeRADIUS includes extended data types which are not defined -# in RFC 2865 or RFC 2866. These data types are: -# -# abinary - Ascend's binary filter format. -# octets - raw octets, printed and input as hex strings. -# e.g.: 0x123456789abcdef -# -# -# Enumerated values are stored in the user file with dictionary -# VALUE translations for easy administration. -# -# Example: -# -# ATTRIBUTE VALUE -# --------------- ----- -# Framed-Protocol = PPP -# 7 = 1 (integer encoding) -# - -# -# Include compatibility dictionary for older users file. Move this -# directive to the end of the file if you want to see the old names -# in the logfiles too. -# -# Includes aren't used. (Jarmo van Lenthe, 23/06/2011) -#$INCLUDE dictionary.compat # compability issues -#$INCLUDE dictionary.acc -#$INCLUDE dictionary.ascend -#$INCLUDE dictionary.bay -#$INCLUDE dictionary.cisco -#$INCLUDE dictionary.livingston -#$INCLUDE dictionary.microsoft -#$INCLUDE dictionary.quintum -#$INCLUDE dictionary.redback -#$INCLUDE dictionary.shasta -#$INCLUDE dictionary.shiva -#$INCLUDE dictionary.tunnel -#$INCLUDE dictionary.usr -#$INCLUDE dictionary.versanet -#$INCLUDE dictionary.erx -#$INCLUDE dictionary.freeradius -#$INCLUDE dictionary.alcatel - -# -# Following are the proper new names. Use these. -# -ATTRIBUTE User-Name 1 string -ATTRIBUTE User-Password 2 string -ATTRIBUTE CHAP-Password 3 octets -ATTRIBUTE NAS-IP-Address 4 ipaddr -ATTRIBUTE NAS-Port 5 integer -ATTRIBUTE Service-Type 6 integer -ATTRIBUTE Framed-Protocol 7 integer -ATTRIBUTE Framed-IP-Address 8 ipaddr -ATTRIBUTE Framed-IP-Netmask 9 ipaddr -ATTRIBUTE Framed-Routing 10 integer -ATTRIBUTE Filter-Id 11 string -ATTRIBUTE Framed-MTU 12 integer -ATTRIBUTE Framed-Compression 13 integer -ATTRIBUTE Login-IP-Host 14 ipaddr -ATTRIBUTE Login-Service 15 integer -ATTRIBUTE Login-TCP-Port 16 integer -ATTRIBUTE Reply-Message 18 string -ATTRIBUTE Callback-Number 19 string -ATTRIBUTE Callback-Id 20 string -ATTRIBUTE Framed-Route 22 string -ATTRIBUTE Framed-IPX-Network 23 ipaddr -ATTRIBUTE State 24 octets -ATTRIBUTE Class 25 octets -ATTRIBUTE Vendor-Specific 26 octets -ATTRIBUTE Session-Timeout 27 integer -ATTRIBUTE Idle-Timeout 28 integer -ATTRIBUTE Termination-Action 29 integer -ATTRIBUTE Called-Station-Id 30 string -ATTRIBUTE Calling-Station-Id 31 string -ATTRIBUTE NAS-Identifier 32 string -ATTRIBUTE Proxy-State 33 octets -ATTRIBUTE Login-LAT-Service 34 string -ATTRIBUTE Login-LAT-Node 35 string -ATTRIBUTE Login-LAT-Group 36 octets -ATTRIBUTE Framed-AppleTalk-Link 37 integer -ATTRIBUTE Framed-AppleTalk-Network 38 integer -ATTRIBUTE Framed-AppleTalk-Zone 39 string - -ATTRIBUTE Acct-Status-Type 40 integer -ATTRIBUTE Acct-Delay-Time 41 integer -ATTRIBUTE Acct-Input-Octets 42 integer -ATTRIBUTE Acct-Output-Octets 43 integer -ATTRIBUTE Acct-Session-Id 44 string -ATTRIBUTE Acct-Authentic 45 integer -ATTRIBUTE Acct-Session-Time 46 integer -ATTRIBUTE Acct-Input-Packets 47 integer -ATTRIBUTE Acct-Output-Packets 48 integer -ATTRIBUTE Acct-Terminate-Cause 49 integer -ATTRIBUTE Acct-Multi-Session-Id 50 string -ATTRIBUTE Acct-Link-Count 51 integer -ATTRIBUTE Acct-Input-Gigawords 52 integer -ATTRIBUTE Acct-Output-Gigawords 53 integer -ATTRIBUTE Event-Timestamp 55 date - -ATTRIBUTE CHAP-Challenge 60 string -ATTRIBUTE NAS-Port-Type 61 integer -ATTRIBUTE Port-Limit 62 integer -ATTRIBUTE Login-LAT-Port 63 integer - -ATTRIBUTE Acct-Tunnel-Connection 68 string - -ATTRIBUTE ARAP-Password 70 string -ATTRIBUTE ARAP-Features 71 string -ATTRIBUTE ARAP-Zone-Access 72 integer -ATTRIBUTE ARAP-Security 73 integer -ATTRIBUTE ARAP-Security-Data 74 string -ATTRIBUTE Password-Retry 75 integer -ATTRIBUTE Prompt 76 integer -ATTRIBUTE Connect-Info 77 string -ATTRIBUTE Configuration-Token 78 string -ATTRIBUTE EAP-Message 79 string -ATTRIBUTE Message-Authenticator 80 octets -ATTRIBUTE ARAP-Challenge-Response 84 string # 10 octets -ATTRIBUTE Acct-Interim-Interval 85 integer -ATTRIBUTE NAS-Port-Id 87 string -ATTRIBUTE Framed-Pool 88 string -ATTRIBUTE NAS-IPv6-Address 95 octets # really IPv6 -ATTRIBUTE Framed-Interface-Id 96 octets # 8 octets -ATTRIBUTE Framed-IPv6-Prefix 97 octets # stupid format -ATTRIBUTE Login-IPv6-Host 98 octets # really IPv6 -ATTRIBUTE Framed-IPv6-Route 99 string -ATTRIBUTE Framed-IPv6-Pool 100 string - -ATTRIBUTE Digest-Response 206 string -ATTRIBUTE Digest-Attributes 207 octets # stupid format - -# -# Experimental Non Protocol Attributes used by Cistron-Radiusd -# - -# These attributes CAN go in the reply item list. -ATTRIBUTE Fall-Through 500 integer -ATTRIBUTE Exec-Program 502 string -ATTRIBUTE Exec-Program-Wait 503 string - -# These attributes CANNOT go in the reply item list. -ATTRIBUTE User-Category 1029 string -ATTRIBUTE Group-Name 1030 string -ATTRIBUTE Huntgroup-Name 1031 string -ATTRIBUTE Simultaneous-Use 1034 integer -ATTRIBUTE Strip-User-Name 1035 integer -ATTRIBUTE Hint 1040 string -ATTRIBUTE Pam-Auth 1041 string -ATTRIBUTE Login-Time 1042 string -ATTRIBUTE Stripped-User-Name 1043 string -ATTRIBUTE Current-Time 1044 string -ATTRIBUTE Realm 1045 string -ATTRIBUTE No-Such-Attribute 1046 string -ATTRIBUTE Packet-Type 1047 integer -ATTRIBUTE Proxy-To-Realm 1048 string -ATTRIBUTE Replicate-To-Realm 1049 string -ATTRIBUTE Acct-Session-Start-Time 1050 date -ATTRIBUTE Acct-Unique-Session-Id 1051 string -ATTRIBUTE Client-IP-Address 1052 ipaddr -ATTRIBUTE Ldap-UserDn 1053 string -ATTRIBUTE NS-MTA-MD5-Password 1054 string -ATTRIBUTE SQL-User-Name 1055 string -ATTRIBUTE LM-Password 1057 octets -ATTRIBUTE NT-Password 1058 octets -ATTRIBUTE SMB-Account-CTRL 1059 integer -ATTRIBUTE SMB-Account-CTRL-TEXT 1061 string -ATTRIBUTE User-Profile 1062 string -ATTRIBUTE Digest-Realm 1063 string -ATTRIBUTE Digest-Nonce 1064 string -ATTRIBUTE Digest-Method 1065 string -ATTRIBUTE Digest-URI 1066 string -ATTRIBUTE Digest-QOP 1067 string -ATTRIBUTE Digest-Algorithm 1068 string -ATTRIBUTE Digest-Body-Digest 1069 string -ATTRIBUTE Digest-CNonce 1070 string -ATTRIBUTE Digest-Nonce-Count 1071 string -ATTRIBUTE Digest-User-Name 1072 string -ATTRIBUTE Pool-Name 1073 string -ATTRIBUTE Ldap-Group 1074 string -ATTRIBUTE Module-Success-Message 1075 string -ATTRIBUTE Module-Failure-Message 1076 string -# X99-Fast 1077 integer - -# -# Non-Protocol Attributes -# These attributes are used internally by the server -# -ATTRIBUTE Auth-Type 1000 integer -ATTRIBUTE Menu 1001 string -ATTRIBUTE Termination-Menu 1002 string -ATTRIBUTE Prefix 1003 string -ATTRIBUTE Suffix 1004 string -ATTRIBUTE Group 1005 string -ATTRIBUTE Crypt-Password 1006 string -ATTRIBUTE Connect-Rate 1007 integer -ATTRIBUTE Add-Prefix 1008 string -ATTRIBUTE Add-Suffix 1009 string -ATTRIBUTE Expiration 1010 date -ATTRIBUTE Autz-Type 1011 integer - -# -# Integer Translations -# - -# User Types - -VALUE Service-Type Login-User 1 -VALUE Service-Type Framed-User 2 -VALUE Service-Type Callback-Login-User 3 -VALUE Service-Type Callback-Framed-User 4 -VALUE Service-Type Outbound-User 5 -VALUE Service-Type Administrative-User 6 -VALUE Service-Type NAS-Prompt-User 7 -VALUE Service-Type Authenticate-Only 8 -VALUE Service-Type Callback-NAS-Prompt 9 -VALUE Service-Type Call-Check 10 -VALUE Service-Type Callback-Administrative 11 - -# Framed Protocols - -VALUE Framed-Protocol PPP 1 -VALUE Framed-Protocol SLIP 2 -VALUE Framed-Protocol ARAP 3 -VALUE Framed-Protocol Gandalf-SLML 4 -VALUE Framed-Protocol Xylogics-IPX-SLIP 5 -VALUE Framed-Protocol X.75-Synchronous 6 - -# Framed Routing Values - -VALUE Framed-Routing None 0 -VALUE Framed-Routing Broadcast 1 -VALUE Framed-Routing Listen 2 -VALUE Framed-Routing Broadcast-Listen 3 - -# Framed Compression Types - -VALUE Framed-Compression None 0 -VALUE Framed-Compression Van-Jacobson-TCP-IP 1 -VALUE Framed-Compression IPX-Header-Compression 2 -VALUE Framed-Compression Stac-LZS 3 - -# Login Services - -VALUE Login-Service Telnet 0 -VALUE Login-Service Rlogin 1 -VALUE Login-Service TCP-Clear 2 -VALUE Login-Service PortMaster 3 -VALUE Login-Service LAT 4 -VALUE Login-Service X25-PAD 5 -VALUE Login-Service X25-T3POS 6 -VALUE Login-Service TCP-Clear-Quiet 7 - -# Login-TCP-Port (see /etc/services for more examples) - -VALUE Login-TCP-Port Telnet 23 -VALUE Login-TCP-Port Rlogin 513 -VALUE Login-TCP-Port Rsh 514 - -# Status Types - -VALUE Acct-Status-Type Start 1 -VALUE Acct-Status-Type Stop 2 -VALUE Acct-Status-Type Interim-Update 3 -VALUE Acct-Status-Type Alive 3 -VALUE Acct-Status-Type Accounting-On 7 -VALUE Acct-Status-Type Accounting-Off 8 -# RFC 2867 Additional Status-Type Values -VALUE Acct-Status-Type Tunnel-Start 9 -VALUE Acct-Status-Type Tunnel-Stop 10 -VALUE Acct-Status-Type Tunnel-Reject 11 -VALUE Acct-Status-Type Tunnel-Link-Start 12 -VALUE Acct-Status-Type Tunnel-Link-Stop 13 -VALUE Acct-Status-Type Tunnel-Link-Reject 14 - -# Authentication Types - -VALUE Acct-Authentic RADIUS 1 -VALUE Acct-Authentic Local 2 - -# Termination Options - -VALUE Termination-Action Default 0 -VALUE Termination-Action RADIUS-Request 1 - -# NAS Port Types - -VALUE NAS-Port-Type Async 0 -VALUE NAS-Port-Type Sync 1 -VALUE NAS-Port-Type ISDN 2 -VALUE NAS-Port-Type ISDN-V120 3 -VALUE NAS-Port-Type ISDN-V110 4 -VALUE NAS-Port-Type Virtual 5 -VALUE NAS-Port-Type PIAFS 6 -VALUE NAS-Port-Type HDLC-Clear-Channel 7 -VALUE NAS-Port-Type X.25 8 -VALUE NAS-Port-Type X.75 9 -VALUE NAS-Port-Type G.3-Fax 10 -VALUE NAS-Port-Type SDSL 11 -VALUE NAS-Port-Type ADSL-CAP 12 -VALUE NAS-Port-Type ADSL-DMT 13 -VALUE NAS-Port-Type IDSL 14 -VALUE NAS-Port-Type Ethernet 15 -VALUE NAS-Port-Type xDSL 16 -VALUE NAS-Port-Type Cable 17 -VALUE NAS-Port-Type Wireless-Other 18 -VALUE NAS-Port-Type Wireless-802.11 19 - -# Acct Terminate Causes, available in 3.3.2 and later - -VALUE Acct-Terminate-Cause User-Request 1 -VALUE Acct-Terminate-Cause Lost-Carrier 2 -VALUE Acct-Terminate-Cause Lost-Service 3 -VALUE Acct-Terminate-Cause Idle-Timeout 4 -VALUE Acct-Terminate-Cause Session-Timeout 5 -VALUE Acct-Terminate-Cause Admin-Reset 6 -VALUE Acct-Terminate-Cause Admin-Reboot 7 -VALUE Acct-Terminate-Cause Port-Error 8 -VALUE Acct-Terminate-Cause NAS-Error 9 -VALUE Acct-Terminate-Cause NAS-Request 10 -VALUE Acct-Terminate-Cause NAS-Reboot 11 -VALUE Acct-Terminate-Cause Port-Unneeded 12 -VALUE Acct-Terminate-Cause Port-Preempted 13 -VALUE Acct-Terminate-Cause Port-Suspended 14 -VALUE Acct-Terminate-Cause Service-Unavailable 15 -VALUE Acct-Terminate-Cause Callback 16 -VALUE Acct-Terminate-Cause User-Error 17 -VALUE Acct-Terminate-Cause Host-Request 18 - -#VALUE Tunnel-Type L2TP 3 -#VALUE Tunnel-Medium-Type IP 1 - -VALUE Prompt No-Echo 0 -VALUE Prompt Echo 1 - -# -# Non-Protocol Integer Translations -# - -VALUE Auth-Type Local 0 -VALUE Auth-Type System 1 -VALUE Auth-Type SecurID 2 -VALUE Auth-Type Crypt-Local 3 -VALUE Auth-Type Reject 4 -VALUE Auth-Type ActivCard 5 -VALUE Auth-Type EAP 6 -VALUE Auth-Type ARAP 7 - -# -# Cistron extensions -# -VALUE Auth-Type Ldap 252 -VALUE Auth-Type Pam 253 -VALUE Auth-Type Accept 254 - -VALUE Auth-Type PAP 1024 -VALUE Auth-Type CHAP 1025 -VALUE Auth-Type LDAP 1026 -VALUE Auth-Type PAM 1027 -VALUE Auth-Type MS-CHAP 1028 -VALUE Auth-Type Kerberos 1029 -VALUE Auth-Type CRAM 1030 -VALUE Auth-Type NS-MTA-MD5 1031 -VALUE Auth-Type CRAM 1032 -VALUE Auth-Type SMB 1033 - -# -# Authorization type, too. -# -VALUE Autz-Type Local 0 - -# -# Experimental Non-Protocol Integer Translations for Cistron-Radiusd -# -VALUE Fall-Through No 0 -VALUE Fall-Through Yes 1 - -VALUE Packet-Type Access-Request 1 -VALUE Packet-Type Access-Accept 2 -VALUE Packet-Type Access-Reject 3 -VALUE Packet-Type Accounting-Request 4 -VALUE Packet-Type Accounting-Response 5 -VALUE Packet-Type Accounting-Status 6 -VALUE Packet-Type Password-Request 7 -VALUE Packet-Type Password-Accept 8 -VALUE Packet-Type Password-Reject 9 -VALUE Packet-Type Accounting-Message 10 -VALUE Packet-Type Access-Challenge 11 -VALUE Packet-Type Status-Server 12 -VALUE Packet-Type Status-Client 13 diff --git a/amelie/tools/templates/send_oauth_link_code.mail b/amelie/tools/templates/send_oauth_link_code.mail index ccbc75e..9862644 100644 --- a/amelie/tools/templates/send_oauth_link_code.mail +++ b/amelie/tools/templates/send_oauth_link_code.mail @@ -12,6 +12,7 @@ You can connect an external account through the following steps:{% endblocktrans {% onlyhtml %}
    • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Go to our website and click "Login"' %} {% onlyhtml %}
    • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Choose your preferred login service and log in' %} +{% onlyhtml %}
    • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Choose the option "Try Another Way"' %} {% onlyhtml %}
    • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Choose the option "I have a link code"' %} {% onlyhtml %}
    • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Enter the following link code:' %} {% onlyhtml %}{% endonlyhtml %}{{ link_code }}{% onlyhtml %}{% endonlyhtml %} {% onlyhtml %}
    • {% endonlyhtml %}{% onlyplain %}- {% endonlyplain %}{% trans 'Your account should now be linked.' %}{% onlyhtml %}
    {% endonlyhtml %} diff --git a/amelie/urls.py b/amelie/urls.py index 505ec94..c70901f 100644 --- a/amelie/urls.py +++ b/amelie/urls.py @@ -23,6 +23,7 @@ path('i18n/', include('django.conf.urls.i18n')), path('profile/', views.profile_overview, name='profile_overview'), path('profile/edit/', views.profile_edit, name='profile_edit'), + path('profile////', views.profile_actions, name='profile_actions'), path('oidc/', include('mozilla_django_oidc.urls')), # General views diff --git a/amelie/videos/templates/videos/videos.html b/amelie/videos/templates/videos/videos.html index 867551d..db660dd 100644 --- a/amelie/videos/templates/videos/videos.html +++ b/amelie/videos/templates/videos/videos.html @@ -30,7 +30,7 @@

    {% if login_voor_meer %}

    - {% url 'login' as login_url %} + {% url 'oidc_authentication_init' as login_url %} {% trans "More videos are available if you" %} {% trans 'logged in' %}.

    {% endif %} diff --git a/amelie/videos/views.py b/amelie/videos/views.py index d515b3f..888736a 100644 --- a/amelie/videos/views.py +++ b/amelie/videos/views.py @@ -204,9 +204,9 @@ def get_form(self, form_class=None): def get(self, request, *args, **kwargs): try: return super(YoutubeVideoCreate, self).get(request, *args, **kwargs) - except (googleapiclient_Error, oauth2client_Error): - #from raven.contrib.django.raven_compat.models import client - #client.captureException() + except (googleapiclient_Error, oauth2client_Error) as e: + import sentry_sdk + sentry_sdk.capture_exception(e) messages.error(request, _("Could not connect to Youtube! " "Please contact the WWW committee if this problem persists.")) @@ -264,9 +264,9 @@ def get_form(self, form_class=None): def get(self, request, *args, **kwargs): try: return super(StreamingVideoCreate, self).get(request, *args, **kwargs) - except (RequestsConnectionError, JSONDecodeError): - #from raven.contrib.django.raven_compat.models import client - #client.captureException() + except (RequestsConnectionError, JSONDecodeError) as e: + import sentry_sdk + sentry_sdk.capture_exception(e) messages.error(request, _("Could not connect to Streaming.IA! " "Please contact the WWW committee if this problem persists.")) diff --git a/amelie/views.py b/amelie/views.py index 09005b0..e01ebc6 100644 --- a/amelie/views.py +++ b/amelie/views.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import login_required +from django.core.exceptions import BadRequest from django.db.models import Q from django.http import HttpResponseRedirect, HttpResponse from django.shortcuts import render, redirect @@ -24,6 +25,7 @@ from amelie.members.models import Person, Committee, StudyPeriod from amelie.education.models import Complaint, EducationEvent from amelie.statistics.decorators import track_hits +from amelie.tools.auth import get_user_info, unlink_totp, unlink_acount from amelie.tools.models import Profile from amelie.videos.models import BaseVideo @@ -133,7 +135,32 @@ def profile_edit(request): @login_required def profile_overview(request): - return render(request, "profile_overview.html") + try: + users = get_user_info(request.user.person) + except Exception as e: + logger.exception(e) + users = [] + return render(request, "profile_overview.html", context={ + 'users': users, + 'providers_unlink_allowed': settings.KEYCLOAK_PROVIDERS_UNLINK_ALLOWED + }) + + +@login_required +def profile_actions(request, action, user_id, arg): + users = get_user_info(request.user.person) + if user_id not in [x['id'] for x in users]: + raise PermissionError("You are not associated with this user.") + if action == "unlink_totp": + if user_id and arg: + unlink_totp(user_id, arg) + elif action == "unlink_social": + if user_id and arg: + unlink_acount(user_id, arg) + else: + raise BadRequest("Unknown action.") + + return redirect("profile_overview") @track_hits("Frontpage") diff --git a/locale/nl/LC_MESSAGES/django.mo b/locale/nl/LC_MESSAGES/django.mo index fd35094..30f8c5c 100644 Binary files a/locale/nl/LC_MESSAGES/django.mo and b/locale/nl/LC_MESSAGES/django.mo differ diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index f8857fa..c280c1a 100644 --- a/locale/nl/LC_MESSAGES/django.po +++ b/locale/nl/LC_MESSAGES/django.po @@ -2,8 +2,8 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-04-11 00:06+0200\n" -"PO-Revision-Date: 2023-04-11 00:06+0018\n" +"POT-Creation-Date: 2023-04-23 12:18+0200\n" +"PO-Revision-Date: 2023-04-23 12:19+0018\n" "Last-Translator: Kevin Alberts <>\n" "Language: en\n" "MIME-Version: 1.0\n" @@ -343,7 +343,7 @@ msgstr "Aangemeld" msgid "Accepted" msgstr "Geaccepteerd" -#: amelie/activities/models.py:539 amelie/activities/views.py:1347 amelie/activities/views.py:1353 amelie/activities/views.py:1359 amelie/members/models.py:279 amelie/personal_tab/templates/info/rfid_cards.html:45 +#: amelie/activities/models.py:539 amelie/activities/views.py:1347 amelie/activities/views.py:1353 amelie/activities/views.py:1359 amelie/members/models.py:279 amelie/personal_tab/templates/info/rfid_cards.html:45 templates/profile_overview.html:213 msgid "Unknown" msgstr "Onbekend" @@ -556,7 +556,7 @@ msgstr "" "\n" "Weet je zeker dat je inschrijfoptie %(object)s voor %(activity)s wilt verwijderen?" -#: amelie/activities/templates/activities/enrollmentoption_confirm_delete.html:19 amelie/activities/templates/activities/enrollmentoption_list.html:47 amelie/activities/templates/activities/enrollmentoption_list.html:58 amelie/activities/templates/activities/enrollmentoption_list.html:94 amelie/activities/templates/activities/enrollmentoption_list.html:106 amelie/activities/templates/activities/enrollmentoption_list.html:139 amelie/activities/templates/activities/enrollmentoption_list.html:148 amelie/activities/templates/activities/enrollmentoption_list.html:181 amelie/activities/templates/activities/enrollmentoption_list.html:190 amelie/activities/templates/activity.html:38 amelie/claudia/templates/claudia/extrapersonalalias_delete.html:19 amelie/companies/templates/companies/company_event.html:20 amelie/education/templates/education_event.html:19 amelie/members/templates/members/payment_confirm_delete.html:19 amelie/members/templates/person_edit_employee.html:13 amelie/personal_tab/templates/cookie_corner/transaction_delete.html:21 amelie/personal_tab/templates/info/rfid_cards.html:63 amelie/room_duty/templates/room_duty/balcony_duty/balcony_duty.html:26 amelie/room_duty/templates/room_duty/balcony_duty/delete.html:22 amelie/room_duty/templates/room_duty/pool/delete.html:42 amelie/room_duty/templates/room_duty/pool/list.html:24 amelie/room_duty/templates/room_duty/pool/persons_change.html:20 amelie/room_duty/templates/room_duty/table/change.html:41 amelie/room_duty/templates/room_duty/table/change_persons.html:20 amelie/room_duty/templates/room_duty/table/delete.html:22 amelie/room_duty/templates/room_duty/table/list.html:24 amelie/room_duty/templates/room_duty/table/overview.html:89 amelie/room_duty/templates/room_duty/table/room_duty_delete.html:30 amelie/room_duty/templates/room_duty/template/change.html:41 amelie/room_duty/templates/room_duty/template/delete.html:22 amelie/room_duty/templates/room_duty/template/list.html:23 amelie/weekmail/templates/weekmail/weekmail_wizard.html:83 templates/oauth2_provider/authorized-token-delete.html:13 templates/oauth2_provider/includes/user_oauth_table.html:9 templates/profile_overview.html:190 +#: amelie/activities/templates/activities/enrollmentoption_confirm_delete.html:19 amelie/activities/templates/activities/enrollmentoption_list.html:47 amelie/activities/templates/activities/enrollmentoption_list.html:58 amelie/activities/templates/activities/enrollmentoption_list.html:94 amelie/activities/templates/activities/enrollmentoption_list.html:106 amelie/activities/templates/activities/enrollmentoption_list.html:139 amelie/activities/templates/activities/enrollmentoption_list.html:148 amelie/activities/templates/activities/enrollmentoption_list.html:181 amelie/activities/templates/activities/enrollmentoption_list.html:190 amelie/activities/templates/activity.html:38 amelie/claudia/templates/claudia/extrapersonalalias_delete.html:19 amelie/companies/templates/companies/company_event.html:20 amelie/education/templates/education_event.html:19 amelie/members/templates/members/payment_confirm_delete.html:19 amelie/members/templates/person_edit_employee.html:13 amelie/personal_tab/templates/cookie_corner/transaction_delete.html:21 amelie/personal_tab/templates/info/rfid_cards.html:63 amelie/room_duty/templates/room_duty/balcony_duty/balcony_duty.html:26 amelie/room_duty/templates/room_duty/balcony_duty/delete.html:22 amelie/room_duty/templates/room_duty/pool/delete.html:42 amelie/room_duty/templates/room_duty/pool/list.html:24 amelie/room_duty/templates/room_duty/pool/persons_change.html:20 amelie/room_duty/templates/room_duty/table/change.html:41 amelie/room_duty/templates/room_duty/table/change_persons.html:20 amelie/room_duty/templates/room_duty/table/delete.html:22 amelie/room_duty/templates/room_duty/table/list.html:24 amelie/room_duty/templates/room_duty/table/overview.html:89 amelie/room_duty/templates/room_duty/table/room_duty_delete.html:30 amelie/room_duty/templates/room_duty/template/change.html:41 amelie/room_duty/templates/room_duty/template/delete.html:22 amelie/room_duty/templates/room_duty/template/list.html:23 amelie/weekmail/templates/weekmail/weekmail_wizard.html:83 templates/oauth2_provider/authorized-token-delete.html:13 templates/oauth2_provider/includes/user_oauth_table.html:9 msgid "Delete" msgstr "Verwijder" @@ -2454,7 +2454,7 @@ msgstr "Inter-Actief-account error" msgid "Inter-Actief account error" msgstr "Inter-Actief-account error" -#: amelie/claudia/templates/accounts/home.html:4 templates/login.html:44 +#: amelie/claudia/templates/accounts/home.html:4 msgid "Inter-Actief account" msgstr "Inter-Actief-account" @@ -7194,32 +7194,32 @@ msgstr "Betalingen" msgid "Totals" msgstr "Totalen" -#: amelie/members/views.py:181 +#: amelie/members/views.py:183 msgid "6 or more" msgstr "6 of meer" -#: amelie/members/views.py:198 +#: amelie/members/views.py:200 msgid "total" msgstr "Totaal" -#: amelie/members/views.py:269 +#: amelie/members/views.py:271 msgid "This person still has an active (unterminated) membership." msgstr "Deze persoon heeft nog een actief (niet beeindigd) lidmaatschap." -#: amelie/members/views.py:273 +#: amelie/members/views.py:275 #, python-brace-format msgid "This person has not paid one of their memberships ({year}/{next_year} {type})." msgstr "Deze persoon heeft een lidmaatschap nog niet betaald ({year}/{next_year} {type})." -#: amelie/members/views.py:282 +#: amelie/members/views.py:284 msgid "This person still has an outstanding balance on their personal tab." msgstr "Deze persoon heeft nog een openstaand streeplijstsaldo." -#: amelie/members/views.py:285 +#: amelie/members/views.py:287 msgid "This person isn't enrolled for one or more future activities." msgstr "Deze persoon staat ingeschreven voor een of meer toekomstige activiteit(en)." -#: amelie/members/views.py:300 +#: amelie/members/views.py:302 msgid "" "This person cannot be anonymized because of the following reasons:\n" "{}" @@ -7227,21 +7227,20 @@ msgstr "" "Deze persoon kan niet worden geanonimiseerd vanwege de volgende redenen:\n" "{}" -#: amelie/members/views.py:1441 +#: amelie/members/views.py:1520 #, python-brace-format -#| msgid "OAuth link code and instructions were sent to {person.incomplete_name()}." msgid "OAuth link code and instructions were sent to {name}." msgstr "OAuth link code en instructies zijn verzonden naar {name}." -#: amelie/members/views.py:1469 +#: amelie/members/views.py:1548 msgid "You are not allowed to manually delete the payments of free memberships." msgstr "Je mag van gratis lidmaatschappen de betaling niet handmatig verwijderen." -#: amelie/members/views.py:1472 +#: amelie/members/views.py:1551 msgid "You are not allowed to delete payments that are already (being) debited." msgstr "Je mag geen betalingen verwijderen die al geïncasseerd zijn/worden." -#: amelie/members/views.py:1476 +#: amelie/members/views.py:1555 #, python-format msgid "Payment %(object)s has been removed" msgstr "Betaling %(object)s is verwijderd" @@ -7543,7 +7542,7 @@ msgstr "" "\n" "Je kunt nu een extern account koppelen via de volgende link:" -#: amelie/oauth/templates/send_token.mail:14 amelie/tools/templates/send_oauth_link_code.mail:21 +#: amelie/oauth/templates/send_token.mail:14 amelie/tools/templates/send_oauth_link_code.mail:22 msgid "" "Best of luck!\n" "\n" @@ -7553,7 +7552,7 @@ msgstr "" "\n" "Met vriendelijke groet," -#: amelie/oauth/templates/token_login.html:5 amelie/personal_tab/templates/pos/show_qr.html:19 amelie/personal_tab/templates/register/register_card_index.html:28 templates/basis.html:265 templates/login.html:4 templates/login.html:64 +#: amelie/oauth/templates/token_login.html:5 amelie/personal_tab/templates/pos/show_qr.html:19 amelie/personal_tab/templates/register/register_card_index.html:28 templates/basis.html:265 templates/login.html:4 templates/login.html:47 msgid "Log in" msgstr "Inloggen" @@ -9235,7 +9234,7 @@ msgstr "Log in om een kaart te registreren" msgid "You can log on with your student account (for example: s0123456) or your Inter-Actief account." msgstr "Je kunt inloggen met je studentaccount (bijvoorbeeld s0123456) of je Inter-Actief-account." -#: amelie/personal_tab/templates/register/register_card_index.html:19 +#: amelie/personal_tab/templates/register/register_card_index.html:19 templates/profile_overview.html:181 templates/profile_overview.html:220 msgid "Username" msgstr "Gebruikersnaam" @@ -9785,27 +9784,27 @@ msgstr "Er zijn nog geen kamerdienstschema-templates." msgid "Add office duty template" msgstr "Kamerdienst-template toevoegen" -#: amelie/settings/generic.py:128 +#: amelie/settings/generic.py:121 msgid "Dutch" msgstr "Nederlands" -#: amelie/settings/generic.py:129 +#: amelie/settings/generic.py:122 msgid "English" msgstr "Engels" -#: amelie/settings/generic.py:663 +#: amelie/settings/generic.py:651 msgid "Access to your name, date of birth, student number, mandate status and committee status." msgstr "Toegang tot je naam, geboortedatum, studentnummer, machtiging- en commissiestatus" -#: amelie/settings/generic.py:664 +#: amelie/settings/generic.py:652 msgid "Access to enrollments for activities and (un)enrolling you for activities." msgstr "Toegang tot inschrijvingen voor activiteiten en het in- en uitschrijven voor activiteiten" -#: amelie/settings/generic.py:665 +#: amelie/settings/generic.py:653 msgid "Access to transactions, direct debit transactions, mandates and RFID-cards." msgstr "Toegang tot transacties, incasso's, machtigingen en RFID-kaarten" -#: amelie/settings/generic.py:666 +#: amelie/settings/generic.py:654 msgid "Access to complaints and sending or supporting complaints in your name." msgstr "Toegang tot onderwijsklachten en het indienen of steunen van onderwijsklachten" @@ -9914,6 +9913,15 @@ msgstr "Populairste snacks:" msgid "Most healthy board member:" msgstr "Gezondste bestuurder:" +#: amelie/tools/auth.py:45 +#, python-brace-format +msgid "You were successfully logged in, but we could not verify your identity. This might be the case if your login session has expired. Please click here to log out completely and try again." +msgstr "Je bent succesvol ingelogd, maar we konden uw identiteit niet verifiëren. Dit kan het geval zijn als uw inlogsessie is verlopen. Klik hier om volledig uit te loggen en probeer het opnieuw." + +#: amelie/tools/auth.py:139 +msgid "You were successfully logged in, but no account could be found. This might be the case if you aren't a member yet, or aren't a member anymore. If you want to become a member you can contact the board." +msgstr "Je bent succesvol ingelogd, maar er kon geen account worden gevonden. Dit kan het geval zijn als je nog geen lid bent of geen lid meer bent. Om lid te worden kun je contact opnemen met het bestuur." + #: amelie/tools/calendar.py:126 msgid "[START] {}" msgstr "[START] {}" @@ -10143,18 +10151,23 @@ msgid "Choose your preferred login service and log in" msgstr "Kies een dienst om mee in te loggen en log in bij die dienst" #: amelie/tools/templates/send_oauth_link_code.mail:15 +#| msgid "Choose the option \"I have a link code\"" +msgid "Choose the option \"Try Another Way\"" +msgstr "Kies de optie \"Probeer op een andere manier\"" + +#: amelie/tools/templates/send_oauth_link_code.mail:16 msgid "Choose the option \"I have a link code\"" msgstr "Kies de optie \"Ik heb een link code\"" -#: amelie/tools/templates/send_oauth_link_code.mail:16 +#: amelie/tools/templates/send_oauth_link_code.mail:17 msgid "Enter the following link code:" msgstr "Voer de volgende link code in:" -#: amelie/tools/templates/send_oauth_link_code.mail:17 +#: amelie/tools/templates/send_oauth_link_code.mail:18 msgid "Your account should now be linked." msgstr "Je account is nu gekoppeld" -#: amelie/tools/templates/send_oauth_link_code.mail:19 +#: amelie/tools/templates/send_oauth_link_code.mail:20 msgid "The link code will remain valid for 3 days, please complete the steps before then or request a new code at the Board." msgstr "De link code is 3 dagen geldig, voer a.u.b. deze stappen binnen die tijd uit of vraag een nieuwe code aan bij het Bestuur." @@ -10405,12 +10418,12 @@ msgstr "Het is niet gelukt om een verbinding met Youtube te maken! Neem contact msgid "Could not connect to Streaming.IA! Please contact the WWW committee if this problem persists." msgstr "Het is niet gelukt om een verbinding met Streaming.IA te maken! Neem contact op met de WWW commissie als dit probleem blijft aanhouden." -#: amelie/views.py:360 +#: amelie/views.py:259 #, python-format msgid "CSRF-check failed. Reason: %s" msgstr "CSRF-controle mislukt. Reden: %s" -#: amelie/views.py:362 +#: amelie/views.py:261 msgid "CSRF-check failed." msgstr "CSRF-controle mislukt." @@ -10925,28 +10938,18 @@ msgid "If you do not have either of these accounts, you can link an external acc msgstr "Als je geen van beide accounts hebt, dan kan je een extern account, zoals een Google-, GitHub-, of Facebook-account, laten koppelen. Neem hiervoor contact op met het bestuur." #: templates/login.html:35 -msgid "Log in with your UTwente account" -msgstr "Log in met je UTwente account" +msgid "Log in with Inter-Actief" +msgstr "Log in bij Inter-Actief" #: templates/login.html:37 msgid "- or -" msgstr "- of -" -#: templates/login.html:42 -msgid "Log in with:" -msgstr "Inloggen met:" +#: templates/login.html:40 +msgid "Log in with legacy account:" +msgstr "Log in met een lokaal account:" -#: templates/login.html:52 -msgid "" -"\n" -" Login with your Inter-Actief or University of Twente account to link it to\n" -" your external account.\n" -" " -msgstr "" -"\n" -"Log in met uw Inter-Actief of Universiteit Twente account om deze te koppelen aan uw externe account." - -#: templates/login.html:67 +#: templates/login.html:50 msgid "Forgot password?" msgstr "Wachtwoord vergeten?" @@ -11292,38 +11295,73 @@ msgstr "" "Hieronder staat een overzicht van apps die toestemming hebben gekregen om je gegevens te gebruiken of wijzigen." #: templates/profile_overview.html:170 -msgid "Login providers" -msgstr "Login-providers" +msgid "User accounts and login providers" +msgstr "Gebruikersaccounts en inlogproviders" -#: templates/profile_overview.html:175 -msgid "The following login providers have been linked to your account:" -msgstr "De volgende login-providers zijn al aan jouw account gekoppeld:" +#: templates/profile_overview.html:174 +msgid "The following user account(s) have been found that are related to you." +msgstr "De volgende gebruikersaccount(s) zijn gevonden die aan u gerelateerd zijn." -#: templates/profile_overview.html:179 -msgid "Login provider" -msgstr "Login-provider" +#: templates/profile_overview.html:177 +msgid "You can link another account by logging out, and logging back in with the Google, LinkedIn or GitHub account you want to link." +msgstr "U kunt een ander account koppelen door uit te loggen, en weer in te loggen met het Google-, LinkedIn- of GitHub-account dat u wilt koppelen." -#: templates/profile_overview.html:180 -msgid "User-ID" -msgstr "Gebruikers-id" +#: templates/profile_overview.html:182 +msgid "Account type" +msgstr "Accounttype" -#: templates/profile_overview.html:198 -msgid "You have not linked any login providers yet." -msgstr "Je hebt nog geen login-providers gekoppeld." +#: templates/profile_overview.html:183 +msgid "Linked social accounts" +msgstr "Gekoppelde sociale accounts" -#: templates/profile_overview.html:203 -msgid "You can add another account to the following login providers:" -msgstr "Je kunt hier (nog) een account koppelen aan de volgende login-providers:" +#: templates/profile_overview.html:190 +msgid "Inter-Actief Active Members account" +msgstr "Inter-Actief-actieveledenaccount" -#: templates/profile_overview.html:210 -#, python-format +#: templates/profile_overview.html:193 +msgid "TOTP devices:" +msgstr "TOTP-apparaten:" + +#: templates/profile_overview.html:197 templates/profile_overview.html:228 +msgid "unlink" +msgstr "ontkoppelen" + +#: templates/profile_overview.html:202 +msgid "No TOTP devices" +msgstr "Geen TOTP-apparaten" + +#: templates/profile_overview.html:203 msgid "" "\n" -" Login using %(backend)s\n" -" " +" To configure a TOTP device, log in with your active members account and visit this page.\n" +" " msgstr "" "\n" -"Inloggen met %(backend)s" +"Om een TOTP-apparaat te configureren, log in met uw actieve ledenaccount en ga naar deze pagina." + +#: templates/profile_overview.html:209 +msgid "Integration account" +msgstr "Integratie-account" + +#: templates/profile_overview.html:211 +msgid "University of Twente account" +msgstr "Universiteit Twente account" + +#: templates/profile_overview.html:219 +msgid "Provider" +msgstr "Dienst" + +#: templates/profile_overview.html:230 +msgid "Cannot unlink" +msgstr "Kan niet ontkoppelen" + +#: templates/profile_overview.html:234 +msgid "No linked accounts" +msgstr "Geen gekoppelde accounts" + +#: templates/profile_overview.html:241 +msgid "No user accounts could be retrieved" +msgstr "Er konden geen accounts opgehaald worden" #: templates/profile_unknown.html:5 templates/profile_unknown.html:11 msgid "Unknown user" @@ -11349,15 +11387,50 @@ msgstr "Log uit en ga terug" msgid "Add photos" msgstr "Foto's toevoegen" +#~ msgid "Log in with:" +#~ msgstr "Inloggen met:" + +#~ msgid "" +#~ "\n" +#~ " Login with your Inter-Actief or University of Twente account to link it to\n" +#~ " your external account.\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "Log in met uw Inter-Actief of Universiteit Twente account om deze te koppelen aan uw externe account." + +#~ msgid "Login providers" +#~ msgstr "Login-providers" + +#~ msgid "The following login providers have been linked to your account:" +#~ msgstr "De volgende login-providers zijn al aan jouw account gekoppeld:" + +#~ msgid "Login provider" +#~ msgstr "Login-provider" + +#~ msgid "User-ID" +#~ msgstr "Gebruikers-id" + +#~ msgid "You have not linked any login providers yet." +#~ msgstr "Je hebt nog geen login-providers gekoppeld." + +#~ msgid "You can add another account to the following login providers:" +#~ msgstr "Je kunt hier (nog) een account koppelen aan de volgende login-providers:" + +#~ msgid "" +#~ "\n" +#~ " Login using %(backend)s\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "Inloggen met %(backend)s" + #~ msgid "The login token does not exist or is expired." #~ msgstr "Het login-token is verlopen of bestaat niet." #~ msgid "Your account is currently not linked to a user. Please contact the board." #~ msgstr "Je gebruikersaccount is niet gekoppeld aan een user. Neem contact op met het bestuur." -#~ msgid "You were successfully logged in, but no account could be found. This might be the case if you aren't a member yet, or aren't a member anymore. If you want to become a member you can contact the board." -#~ msgstr "Je bent succesvol ingelogd, maar er kon geen account worden gevonden. Dit kan het geval zijn als je nog geen lid bent of geen lid meer bent. Om lid te worden kun je contact opnemen met het bestuur." - #~ msgid "You can log in with your UTwente account, or your Inter-Actief account." #~ msgstr "Je kunt inloggen met je UTwente-account, of met je Inter-Actief-account." @@ -13031,9 +13104,6 @@ msgstr "Foto's toevoegen" #~ msgid "Generated classes." #~ msgstr "Gegenereerde colleges" -#~ msgid "Provided to" -#~ msgstr "Gegeven aan" - #~ msgid "Gender is" #~ msgstr "Geslacht is" diff --git a/requirements.txt b/requirements.txt index 2b23403..d9a8c29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django>=3.2.16,<3.3 +Django>=3.2.16,<3.2.18 # Limited because of issue https://github.com/Inter-Actief/amelie/issues/760 importlib-metadata>=1.4.0,<5.0 # importlib-metadata 5+ causes Celery bug: https://github.com/celery/celery/issues/7783 celery>=5.2.7,<5.3 @@ -66,9 +66,6 @@ oauth2client>=4.1.3,<4.2 # Mastodon API wrapper Mastodon.py>=1.5.1,<1.6 -social-auth-core[openidconnect] >= 4.1.0 -social-auth-app-django>=5.0.0,<5.1 - # Development django-debug-toolbar>=3.7,<3.8 django-rosetta>=0.9.8,<0.10 @@ -77,12 +74,12 @@ pyinotify>=0.9.6,<0.10 model_bakery>=1.7.0,<1.8 django-silk>=5.0.1,<5.1 -# SAML SP (requires xmlsec1, libssl-dev and libsasl2-dev (on Debian)) -djangosaml2>=1.5.3,<1.6 - # SAML IdP (requires xmlsec1 (on Debian)) pysaml2>=7.2.1,<7.3 djangosaml2idp>=0.7.2,<0.8 # Single Sign On - OIDC Client mozilla-django-oidc + +# Sentry error logging +sentry-sdk>=1.22.1,<1.23 diff --git a/templates/basis.html b/templates/basis.html index 2e18ab2..154c380 100644 --- a/templates/basis.html +++ b/templates/basis.html @@ -1,6 +1,6 @@ -{% load i18n banners active_menu compress cached_static provider_name %} +{% load i18n banners active_menu compress cached_static %} {% get_current_language as LANGUAGE_CODE %} diff --git a/templates/login.html b/templates/login.html index 9b8f3d6..396ebb7 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,5 +1,5 @@ {% extends "basis.html" %} -{% load i18n provider_name %} +{% load i18n %} {% block titel %}{% trans 'Log in' %}{% endblock titel %} diff --git a/templates/profile_overview.html b/templates/profile_overview.html index c85310d..1ccddf2 100644 --- a/templates/profile_overview.html +++ b/templates/profile_overview.html @@ -1,5 +1,5 @@ {% extends "basis.html" %} -{% load i18n fieldsets provider_name %} +{% load i18n fieldsets %} {% block titel %} {% trans 'Profile overview' %} @@ -167,53 +167,81 @@

    {% trans 'Apps' %}

  • -

    {% trans 'Login providers' %}

    +

    {% trans 'User accounts and login providers' %}

    - {% if backends.associated %} -

    - {% trans 'The following login providers have been linked to your account:' %} -

    - - - - - - - {% for assoc in backends.associated %} - - - - - - {% endfor %} -
    {% trans 'Login provider' %}{% trans 'User-ID' %}
    {{ assoc.provider|provider_name }}{{ assoc.uid }} -
    - {% csrf_token %} - -
    -
    - {% else %} -

    - {% trans 'You have not linked any login providers yet.' %} -

    - {% endif %} -

    - {% trans 'You can add another account to the following login providers:' %} + {% trans 'The following user account(s) have been found that are related to you.' %}

    - -
      - {% for backend in backends.backends %} -
    • - - {% blocktrans with backend|provider_name as backend %} - Login using {{ backend }} - {% endblocktrans %} - -
    • +

      + {% trans 'You can link another account by logging out, and logging back in with the Google, LinkedIn or GitHub account you want to link.' %} +

      + + + + + + + {% for user in users %} + + + + + + {% empty %} + + + {% endfor %} - +
      {% trans 'Username' %}{% trans 'Account type' %}{% trans 'Linked social accounts' %}
      {{ user.username }} + {% if 'LDAP_ENTRY_DN' in user.attributes %} + {% trans "Inter-Actief Active Members account" %}
      +
      + {% if user.totp %} + {% trans 'TOTP devices:' %}
      +
        + {% for credential in user.credentials %} + {% if credential.type == "otp" %} +
      • {{ credential.userLabel }} ({% trans 'unlink' %})
      • + {% endif %} + {% endfor %} +
      + {% else %} + {% trans 'No TOTP devices' %}
      + {% blocktrans %} + To configure a TOTP device, log in with your active members account and visit this page. + {% endblocktrans %} + {% endif %} + + {% elif 'created_by' in user.attributes and 'amelie' in user.attributes.created_by %} + {% trans "Integration account" %} + {% elif 'created_by' in user.attributes and 'ut-saml' in user.attributes.created_by %} + {% trans "University of Twente account" %} + {% else %} + {% trans "Unknown" %} + {% endif %} +
      + + + + + + + {% for identity in user.federatedIdentities %} + + + + + + {% empty %} + + {% endfor %} +
      {% trans 'Provider' %}{% trans 'Username' %}
      {{ identity.identityProvider }}{{ identity.userName }}{% if identity.identityProvider in providers_unlink_allowed %} + ({% trans 'unlink' %}) + {% else %} + {% trans 'Cannot unlink' %} + {% endif %}
      {% trans 'No linked accounts' %}
      +
      {% trans 'No user accounts could be retrieved' %}