You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ma...@apache.org on 2021/12/10 21:42:53 UTC

[airavata-django-portal] branch develop updated (8ad248b -> cd31d0f)

This is an automated email from the ASF dual-hosted git repository.

machristie pushed a change to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git.


    from 8ad248b  Update sdk version to 1.3.0
     new 38f28cc  AIRAVATA-3468 Check if profile is complete and redirect to profile editor if not
     new 26e2f05  AIRAVATA-3468 Store IDP userinfo
     new 0dc515f  AIRAVATA-3468 configuration of URLs for retrieving external IDP userinfo
     new 3013690  AIRAVATA-3319 Admin API for updating a user's username
     new 683f1fd  AIRAVATA-3319 Add admin UI for updating user's username
     new 555b19d  AIRAVATA-3468 Disabled username editing by users
     new 06040f9  AIRAVATA-3468 Allow /media/ in completeness check middleware (gateway logo)
     new 81a4c17  AIRAVATA-3468 Inform user that they must complete profile
     new 6b06651  AIRAVATA-3319 Add admin email alerting when user ends up with invalid username
     new c6efb48  AIRAVATA-3468 Add link for navigating back to the dashboard
     new 899cc90  AIRAVATA-3468 Only redirect web page (Accepts: text/html) requests to complete profile
     new ef6c1a0  AIRAVATA-3468 Create user profile if it doesn't exist
     new a3981d3  AIRAVATA-3468 Separately check if username is invalid
     new 0295eba  AIRAVATA-3319 Alert admins if username isn't valid and provide a means to update it
     new b6f6b76  AIRAVATA-3319 Clarify username_initialized and is_username_valid rules
     new 2166d14  AIRAVATA-3319 Allow user with invalid username to complete rest of user profile
     new 1026fc7  AIRAVATA-3319 Remove username_locked since it's not needed
     new 133ff73  AIRAVATA-3319 newUsername only needed when changing username
     new f383637  AIRAVATA-3319 Reorganized manage users detail into tabs
     new e6bb6cb  AIRAVATA-3319 Adds external IDP userinfo display (if available) to Manage Users
     new 58ab191  AIRAVATA-3319 Show user profile fields with validity, and informational alerts
     new 3b21298  AIRAVATA-3319 Fix warning message in change username confirmation
     new 66393d5  AIRAVATA-3319 merge migration
     new cd31d0f  AIRAVATA-3319 Fix test

The 24 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 django_airavata/apps/admin/package.json            |   1 +
 .../src/components/users/ActivateUserPanel.vue     |   4 +-
 .../src/components/users/ChangeUsernamePanel.vue   | 105 ++++++++++++
 .../src/components/users/EditGroupsPanel.vue       |  68 ++++++++
 .../components/users/ExternalIDPUserInfoPanel.vue  |  34 ++++
 .../IdentityServiceUserManagementContainer.vue     |   8 +
 .../src/components/users/UserDetailsContainer.vue  | 138 +++++++++-------
 .../src/components/users/UserProfilePanel.vue      |  65 ++++++++
 django_airavata/apps/admin/yarn.lock               |   5 +
 django_airavata/apps/api/serializers.py            |  34 ++++
 .../js/models/IAMUserProfile.js                    |   5 +
 .../static/django_airavata_api/js/models/User.js   |  11 +-
 .../django_airavata_api/js/service_config.js       |   8 +
 django_airavata/apps/api/views.py                  |  22 +++
 django_airavata/apps/auth/backends.py              |  75 ++++++++-
 django_airavata/apps/auth/iam_admin_client.py      |  66 ++++++++
 django_airavata/apps/auth/middleware.py            |  33 +++-
 .../auth/migrations/0009_auto_20210625_1725.py     |  41 +++++
 .../0010_userprofile_username_initialized.py       |  18 +++
 .../0011_remove_userprofile_username_locked.py     |  17 ++
 .../auth/migrations/0012_merge_20211210_2041.py    |  14 ++
 django_airavata/apps/auth/models.py                |  82 +++++++++-
 django_airavata/apps/auth/serializers.py           |  34 +++-
 django_airavata/apps/auth/signals.py               |  17 +-
 .../js/components/UserProfileEditor.vue            |  21 ++-
 .../js/containers/UserProfileContainer.vue         |  21 +++
 django_airavata/apps/auth/tests/test_backends.py   | 178 +++++++++++++++++++++
 django_airavata/apps/auth/utils.py                 |  44 +++++
 django_airavata/apps/auth/views.py                 |  21 ++-
 django_airavata/settings.py                        |   1 +
 .../{DeleteButton.vue => ConfirmationButton.vue}   |  26 ++-
 django_airavata/static/common/js/index.js          |   2 +
 32 files changed, 1114 insertions(+), 105 deletions(-)
 create mode 100644 django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue
 create mode 100644 django_airavata/apps/admin/static/django_airavata_admin/src/components/users/EditGroupsPanel.vue
 create mode 100644 django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ExternalIDPUserInfoPanel.vue
 create mode 100644 django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserProfilePanel.vue
 create mode 100644 django_airavata/apps/auth/migrations/0009_auto_20210625_1725.py
 create mode 100644 django_airavata/apps/auth/migrations/0010_userprofile_username_initialized.py
 create mode 100644 django_airavata/apps/auth/migrations/0011_remove_userprofile_username_locked.py
 create mode 100644 django_airavata/apps/auth/migrations/0012_merge_20211210_2041.py
 create mode 100644 django_airavata/apps/auth/tests/test_backends.py
 copy django_airavata/static/common/js/components/{DeleteButton.vue => ConfirmationButton.vue} (52%)

[airavata-django-portal] 11/24: AIRAVATA-3468 Only redirect web page (Accepts: text/html) requests to complete profile

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 899cc906edaf7480c7248aef4d35eebdaf06f108
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Mon Aug 2 10:00:43 2021 -0400

    AIRAVATA-3468 Only redirect web page (Accepts: text/html) requests to complete profile
---
 django_airavata/apps/auth/middleware.py | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/django_airavata/apps/auth/middleware.py b/django_airavata/apps/auth/middleware.py
index 78ec89c..ba49ec3 100644
--- a/django_airavata/apps/auth/middleware.py
+++ b/django_airavata/apps/auth/middleware.py
@@ -83,10 +83,13 @@ def user_profile_completeness_check(get_response):
         if not request.user.is_authenticated:
             return get_response(request)
 
+        allowed_paths = [
+            reverse('django_airavata_auth:user_profile'),
+            reverse('django_airavata_auth:logout'),
+        ]
         if (not request.user.user_profile.is_complete and
-            request.path != reverse('django_airavata_auth:user_profile') and
-            request.path != reverse('django_airavata_auth:logout') and
-                request.META['HTTP_ACCEPT'] != 'application/json') and not request.path.startswith("/media/"):
+            request.path not in allowed_paths and
+                'text/html' in request.META['HTTP_ACCEPT']):
             return redirect('django_airavata_auth:user_profile')
         else:
             return get_response(request)

[airavata-django-portal] 13/24: AIRAVATA-3468 Separately check if username is invalid

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit a3981d33d8fdbc916f09d5edba949a944e46d8a3
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Mon Oct 11 16:46:54 2021 -0400

    AIRAVATA-3468 Separately check if username is invalid
---
 .../static/django_airavata_api/js/models/User.js   |  1 +
 django_airavata/apps/auth/models.py                |  3 +-
 django_airavata/apps/auth/serializers.py           |  7 +++-
 .../js/components/UserProfileEditor.vue            | 38 +++++++++-------------
 .../js/containers/UserProfileContainer.vue         | 12 +++++--
 5 files changed, 34 insertions(+), 27 deletions(-)

diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/User.js b/django_airavata/apps/api/static/django_airavata_api/js/models/User.js
index 648721a..e414444 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/User.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/User.js
@@ -8,6 +8,7 @@ const FIELDS = [
   "email",
   "pending_email_change",
   "complete",
+  "username_valid"
 ];
 
 export default class User extends BaseModel {
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index 15e86c1..726efce 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -1,10 +1,10 @@
 import uuid
 
 from django.conf import settings
+from django.core.exceptions import ValidationError
 from django.db import models
 
 from . import forms
-from django.core.exceptions import ValidationError
 
 VERIFY_EMAIL_TEMPLATE = 1
 NEW_USER_EMAIL_TEMPLATE = 2
@@ -75,6 +75,7 @@ class UserProfile(models.Model):
             validates = True
         except ValidationError:
             validates = False
+        # TODO: should be valid if matching an old email address too
         return (validates or (self.is_email_valid and self.user.email == self.user.username))
 
     @property
diff --git a/django_airavata/apps/auth/serializers.py b/django_airavata/apps/auth/serializers.py
index 29e2ef9..2eac92a 100644
--- a/django_airavata/apps/auth/serializers.py
+++ b/django_airavata/apps/auth/serializers.py
@@ -24,10 +24,12 @@ class UserSerializer(serializers.ModelSerializer):
 
     pending_email_change = serializers.SerializerMethodField()
     complete = serializers.SerializerMethodField()
+    username_valid = serializers.SerializerMethodField()
 
     class Meta:
         model = get_user_model()
-        fields = ['id', 'username', 'first_name', 'last_name', 'email', 'pending_email_change', 'complete']
+        fields = ['id', 'username', 'first_name', 'last_name', 'email',
+                  'pending_email_change', 'complete', 'username_valid']
 
     def get_pending_email_change(self, instance):
         request = self.context['request']
@@ -41,6 +43,9 @@ class UserSerializer(serializers.ModelSerializer):
     def get_complete(self, instance):
         return instance.user_profile.is_complete
 
+    def get_username_valid(self, instance):
+        return instance.user_profile.is_username_valid
+
     @atomic
     def update(self, instance, validated_data):
         request = self.context['request']
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
index a59050b..39447d1 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
@@ -1,32 +1,24 @@
 <template>
   <b-card>
-    <b-form-group label="Username">
-      <b-form-input
-        v-model="$v.user.username.$model"
-        :disabled="true"
-        :state="validateState($v.user.username)"
-      />
-      <b-form-invalid-feedback v-if="!$v.user.username.emailOrMatchesRegex">
-        Username can only contain lowercase letters, numbers, underscores and
-        hyphens OR it can be the same as the email address. Only an
-        administrator can update your username to a valid value.
-      </b-form-invalid-feedback>
+    <!-- TODO: add help text that only administrators can change a user's username -->
+    <b-form-group label="Username" :disabled="true">
+      <b-form-input v-model="user.username" />
     </b-form-group>
-    <b-form-group label="First Name">
+    <b-form-group label="First Name" :disabled="disabled">
       <b-form-input
         v-model="$v.user.first_name.$model"
         @keydown.native.enter="save"
         :state="validateState($v.user.first_name)"
       />
     </b-form-group>
-    <b-form-group label="Last Name">
+    <b-form-group label="Last Name" :disabled="disabled">
       <b-form-input
         v-model="$v.user.last_name.$model"
         @keydown.native.enter="save"
         :state="validateState($v.user.last_name)"
       />
     </b-form-group>
-    <b-form-group label="Email">
+    <b-form-group label="Email" :disabled="disabled">
       <b-form-input
         v-model="$v.user.email.$model"
         @keydown.native.enter="save"
@@ -45,7 +37,7 @@
         ></b-alert
       >
     </b-form-group>
-    <b-button variant="primary" @click="save" :disabled="$v.$invalid"
+    <b-button variant="primary" @click="save" :disabled="$v.$invalid || disabled"
       >Save</b-button
     >
   </b-card>
@@ -55,7 +47,7 @@
 import { models } from "django-airavata-api";
 import { errors } from "django-airavata-common-ui";
 import { validationMixin } from "vuelidate";
-import { email, helpers, or, required, sameAs } from "vuelidate/lib/validators";
+import { email, required } from "vuelidate/lib/validators";
 
 export default {
   name: "user-profile-editor",
@@ -65,9 +57,15 @@ export default {
       type: models.User,
       required: true,
     },
+    disabled: {
+      type: Boolean,
+      default: false,
+    },
   },
   created() {
-    this.$v.user.$touch();
+    if (!this.disabled) {
+      this.$v.user.$touch();
+    }
   },
   data() {
     return {
@@ -75,14 +73,8 @@ export default {
     };
   },
   validations() {
-    const usernameRegex = helpers.regex("username", /^[a-z0-9_-]+$/);
-    const emailOrMatchesRegex = or(usernameRegex, sameAs("email"));
     return {
       user: {
-        username: {
-          required,
-          emailOrMatchesRegex,
-        },
         first_name: {
           required,
         },
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
index 3a37faa..03f7b3f 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
@@ -1,16 +1,24 @@
 <template>
   <div>
     <h1 class="h4 mb-4">User Profile Editor</h1>
-    <b-alert :show="user && !user.complete"
+    <b-alert v-if="user && !user.username_valid" show variant="danger">
+      Unfortunately the username on your profile is invalid, which prevents
+      creating or updating your user profile. The administrators have been
+      notified and will be able to update your user account with a valid
+      username. Someone will notify you once your username has been updated to a
+      valid value.
+    </b-alert>
+    <b-alert v-else-if="user && !user.complete" show>
       >Please complete your user profile before continuing.</b-alert
     >
     <user-profile-editor
       v-if="user"
       v-model="user"
+      :disabled="!user.username_valid"
       @save="onSave"
       @resend-email-verification="resendEmailVerification"
     />
-    <b-link class="text-muted small" href="/workspace/dashboard"
+    <b-link v-if="user && user.complete" class="text-muted small" href="/workspace/dashboard"
       >Return to Dashboard</b-link
     >
   </div>

[airavata-django-portal] 20/24: AIRAVATA-3319 Adds external IDP userinfo display (if available) to Manage Users

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit e6bb6cb522e4f02eba44bb8fc06ad88300ce529f
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Dec 10 09:38:47 2021 -0500

    AIRAVATA-3319 Adds external IDP userinfo display (if available) to Manage Users
---
 .../components/users/ExternalIDPUserInfoPanel.vue  | 32 ++++++++++++++++++++++
 .../src/components/users/UserDetailsContainer.vue  | 14 +++++++++-
 django_airavata/apps/api/serializers.py            | 18 ++++++++++++
 .../js/models/IAMUserProfile.js                    |  1 +
 4 files changed, 64 insertions(+), 1 deletion(-)

diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ExternalIDPUserInfoPanel.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ExternalIDPUserInfoPanel.vue
new file mode 100644
index 0000000..22de442
--- /dev/null
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ExternalIDPUserInfoPanel.vue
@@ -0,0 +1,32 @@
+<template>
+  <b-card header="External IDP Userinfo">
+    <b-table :items="items" small borderless sort-by="claim" />
+    <small class="text-muted"
+      >IDP alias is {{ externalIDPUserInfo.idp_alias || "N/A" }}</small
+    >
+  </b-card>
+</template>
+
+<script>
+export default {
+  name: "external-idp-user-info-panel",
+  props: ["externalIDPUserInfo"],
+  computed: {
+    userinfo() {
+      return this.externalIDPUserInfo.userinfo
+        ? this.externalIDPUserInfo.userinfo
+        : {};
+    },
+    items() {
+      return Object.keys(this.userinfo).map((claim) => {
+        return {
+          claim: claim,
+          value: this.externalIDPUserInfo.userinfo[claim],
+        };
+      });
+    },
+  },
+};
+</script>
+
+<style></style>
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
index f78dca1..9f56cf2 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
@@ -11,6 +11,10 @@
         :airavata-internal-user-id="iamUserProfile.airavataInternalUserId"
         @save="groupsUpdated"
       />
+      <external-idp-user-info-panel
+        v-if="hasExternalIDPUserInfo"
+        :externalIDPUserInfo="localIAMUserProfile.externalIDPUserInfo"
+      />
     </b-tab>
     <b-tab
       title="Troubleshooting"
@@ -53,6 +57,7 @@ import EnableUserPanel from "./EnableUserPanel";
 import DeleteUserPanel from "./DeleteUserPanel";
 import ChangeUsernamePanel from "./ChangeUsernamePanel.vue";
 import EditGroupsPanel from "./EditGroupsPanel.vue";
+import ExternalIDPUserInfoPanel from "./ExternalIDPUserInfoPanel.vue";
 
 export default {
   name: "user-details-container",
@@ -73,6 +78,7 @@ export default {
     ActivateUserPanel,
     ChangeUsernamePanel,
     EditGroupsPanel,
+    "external-idp-user-info-panel": ExternalIDPUserInfoPanel,
   },
   data() {
     return {
@@ -90,6 +96,12 @@ export default {
       this.$emit("groups-updated", this.localIAMUserProfile);
     },
   },
-  computed: {},
+  computed: {
+    hasExternalIDPUserInfo() {
+      return (
+        Object.keys(this.localIAMUserProfile.externalIDPUserInfo).length !== 0
+      );
+    },
+  },
 };
 </script>
diff --git a/django_airavata/apps/api/serializers.py b/django_airavata/apps/api/serializers.py
index 11f6646..a939d0c 100644
--- a/django_airavata/apps/api/serializers.py
+++ b/django_airavata/apps/api/serializers.py
@@ -58,6 +58,7 @@ from airavata.model.workspace.ttypes import (
 )
 from airavata_django_portal_sdk import user_storage
 from django.conf import settings
+from django.contrib.auth import get_user_model
 from django.urls import reverse
 from rest_framework import serializers
 
@@ -953,6 +954,7 @@ class IAMUserProfile(serializers.Serializer):
         lookup_url_kwarg='user_id')
     userHasWriteAccess = serializers.SerializerMethodField()
     newUsername = serializers.CharField(write_only=True, required=False)
+    externalIDPUserInfo = serializers.SerializerMethodField()
 
     def update(self, instance, validated_data):
         existing_group_ids = [group.id for group in instance['groups']]
@@ -967,6 +969,22 @@ class IAMUserProfile(serializers.Serializer):
         request = self.context['request']
         return request.is_gateway_admin
 
+    def get_externalIDPUserInfo(self, userProfile):
+
+        result = {}
+        try:
+            if get_user_model().objects.filter(username=userProfile['userId']).exists():
+                django_user = get_user_model().objects.get(username=userProfile['userId'])
+                claims = django_user.user_profile.idp_userinfo.all()
+                if claims.exists():
+                    result['idp_alias'] = claims.first().idp_alias
+                    result['userinfo'] = {}
+                for claim in claims:
+                    result['userinfo'][claim.claim] = claim.value
+        except Exception as e:
+            log.warning(f"Failed to load idp_userinfo for {userProfile['userId']}", exc_info=e)
+        return result
+
 
 class AckNotificationSerializer(serializers.ModelSerializer):
     class Meta:
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/IAMUserProfile.js b/django_airavata/apps/api/static/django_airavata_api/js/models/IAMUserProfile.js
index d6c2d89..5f804e0 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/IAMUserProfile.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/IAMUserProfile.js
@@ -22,6 +22,7 @@ const FIELDS = [
     list: true,
   },
   "userHasWriteAccess",
+  "externalIDPUserInfo",
 ];
 
 export default class IAMUserProfile extends BaseModel {

[airavata-django-portal] 02/24: AIRAVATA-3468 Store IDP userinfo

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 26e2f053c38d616db4ad2bba7a04b9828d6b1dc8
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Jun 25 13:39:14 2021 -0400

    AIRAVATA-3468 Store IDP userinfo
    
    This is mostly for debugging purposes, to see what is and isn't included in CILogon userinfo.
---
 django_airavata/apps/auth/backends.py              | 34 +++++++++++++++++-
 .../auth/migrations/0009_auto_20210625_1725.py     | 41 ++++++++++++++++++++++
 django_airavata/apps/auth/models.py                | 17 +++++++++
 django_airavata/apps/auth/views.py                 |  5 +--
 4 files changed, 94 insertions(+), 3 deletions(-)

diff --git a/django_airavata/apps/auth/backends.py b/django_airavata/apps/auth/backends.py
index 96c57fd..7202ec7 100644
--- a/django_airavata/apps/auth/backends.py
+++ b/django_airavata/apps/auth/backends.py
@@ -29,7 +29,8 @@ class KeycloakBackend(object):
                      request=None,
                      username=None,
                      password=None,
-                     refresh_token=None):
+                     refresh_token=None,
+                     idp_alias=None):
         try:
             user = None
             access_token = None
@@ -75,6 +76,9 @@ class KeycloakBackend(object):
                     request)
                 self._process_token(request, token)
                 user = self._process_userinfo(request, userinfo)
+                if idp_alias is not None:
+                    self._store_idp_userinfo(user, token, idp_alias)
+                # TODO: if idp_alias, add idp userinfo too
                 access_token = token['access_token']
             # authz_token_middleware has already run, so must manually add
             # the `request.authz_token` attribute
@@ -270,3 +274,31 @@ class KeycloakBackend(object):
                 user_profile.save()
                 user_profile.userinfo_set.create(claim='sub', value=sub)
                 return user
+
+    def _store_idp_userinfo(self, user, token, idp_alias):
+        try:
+            access_token = token['access_token']
+            logger.debug(f"access_token={access_token} for idp_alias={idp_alias}")
+            # fetch the idp's token
+            headers = {'Authorization': f'Bearer {access_token}'}
+            # For the following to work, in Keycloak the IDP should have 'Store
+            # Tokens' and 'Stored Tokens Readable' enabled and the user needs
+            # the broker/read-token role
+            r = requests.get(f"https://iamdev.scigap.org/auth/realms/seagrid/broker/{idp_alias}/token", headers=headers)
+            idp_token = r.json()
+            idp_headers = {'Authorization': f"Bearer {idp_token['access_token']}"}
+            r = requests.get("https://cilogon.org/oauth2/userinfo", headers=idp_headers)
+            userinfo = r.json()
+            logger.debug(f"userinfo={userinfo}")
+
+            # Save the idp user info claims
+            user_profile = user.user_profile
+            for (claim, value) in userinfo.items():
+                if user_profile.idp_userinfo.filter(idp_alias=idp_alias, claim=claim).exists():
+                    userinfo_claim = user_profile.idp_userinfo.get(idp_alias=idp_alias, claim=claim)
+                    userinfo_claim.value = value
+                    userinfo_claim.save()
+                else:
+                    user_profile.idp_userinfo.create(idp_alias=idp_alias, claim=claim, value=value)
+        except Exception:
+            logger.exception(f"Failed to store IDP userinfo for {user.username} from IDP {idp_alias}")
diff --git a/django_airavata/apps/auth/migrations/0009_auto_20210625_1725.py b/django_airavata/apps/auth/migrations/0009_auto_20210625_1725.py
new file mode 100644
index 0000000..1d38199
--- /dev/null
+++ b/django_airavata/apps/auth/migrations/0009_auto_20210625_1725.py
@@ -0,0 +1,41 @@
+# Generated by Django 2.2.23 on 2021-06-25 17:25
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('django_airavata_auth', '0008_auto_20210422_1838'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='userinfo',
+            name='created_date',
+            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='userinfo',
+            name='updated_date',
+            field=models.DateTimeField(auto_now=True),
+        ),
+        migrations.CreateModel(
+            name='IDPUserInfo',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('idp_alias', models.CharField(max_length=64)),
+                ('claim', models.CharField(max_length=64)),
+                ('value', models.CharField(max_length=255)),
+                ('created_date', models.DateTimeField(auto_now_add=True)),
+                ('updated_date', models.DateTimeField(auto_now=True)),
+                ('user_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='idp_userinfo', to='django_airavata_auth.UserProfile')),
+            ],
+            options={
+                'unique_together': {('user_profile', 'claim', 'idp_alias')},
+            },
+        ),
+    ]
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index 2383992..15e86c1 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -99,6 +99,8 @@ class UserInfo(models.Model):
     claim = models.CharField(max_length=64)
     value = models.CharField(max_length=255)
     user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
+    created_date = models.DateTimeField(auto_now_add=True)
+    updated_date = models.DateTimeField(auto_now=True)
 
     class Meta:
         unique_together = ['user_profile', 'claim']
@@ -107,6 +109,21 @@ class UserInfo(models.Model):
         return f"{self.claim}={self.value}"
 
 
+class IDPUserInfo(models.Model):
+    idp_alias = models.CharField(max_length=64)
+    claim = models.CharField(max_length=64)
+    value = models.CharField(max_length=255)
+    user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name="idp_userinfo")
+    created_date = models.DateTimeField(auto_now_add=True)
+    updated_date = models.DateTimeField(auto_now=True)
+
+    class Meta:
+        unique_together = ['user_profile', 'claim', 'idp_alias']
+
+    def __str__(self):
+        return f"{self.idp_alias}: {self.claim}={self.value}"
+
+
 class PendingEmailChange(models.Model):
     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
     email_address = models.EmailField()
diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py
index 1817879..df0ac42 100644
--- a/django_airavata/apps/auth/views.py
+++ b/django_airavata/apps/auth/views.py
@@ -146,7 +146,9 @@ def start_logout(request):
 def callback(request):
     try:
         login_desktop = request.GET.get('login_desktop', "false") == "true"
-        user = authenticate(request=request)
+        idp_alias = request.GET.get('idp_alias')
+        user = authenticate(request=request, idp_alias=idp_alias)
+
         if user is not None:
             login(request, user)
             if login_desktop:
@@ -161,7 +163,6 @@ def callback(request):
         messages.error(
             request,
             "Failed to process OAuth2 callback: {}".format(str(err)))
-        idp_alias = request.GET.get('idp_alias')
         if login_desktop:
             return _create_login_desktop_failed_response(
                 request, idp_alias=idp_alias)

[airavata-django-portal] 06/24: AIRAVATA-3468 Disabled username editing by users

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 555b19da0bc2b199fc0dbd847372820531eaad7b
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Wed Jul 7 13:50:28 2021 -0400

    AIRAVATA-3468 Disabled username editing by users
---
 .../django_airavata_auth/js/components/UserProfileEditor.vue      | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
index b6b31fe..23b9104 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
@@ -3,12 +3,13 @@
     <b-form-group label="Username">
       <b-form-input
         v-model="$v.user.username.$model"
-        @keydown.native.enter="save"
+        :disabled="true"
         :state="validateState($v.user.username)"
       />
       <b-form-invalid-feedback v-if="!$v.user.username.emailOrMatchesRegex">
         Username can only contain lowercase letters, numbers, underscores and
-        hyphens OR it can be the same as the email address.
+        hyphens OR it can be the same as the email address. Only an
+        administrator can update your username to a valid value.
       </b-form-invalid-feedback>
     </b-form-group>
     <b-form-group label="First Name">
@@ -65,6 +66,9 @@ export default {
       required: true,
     },
   },
+  created() {
+    this.$v.user.$touch();
+  },
   data() {
     return {
       user: this.cloneValue(),

[airavata-django-portal] 16/24: AIRAVATA-3319 Allow user with invalid username to complete rest of user profile

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 2166d14923fc5eff554cbdce5d63999c3100fa79
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Tue Dec 7 11:39:10 2021 -0500

    AIRAVATA-3319 Allow user with invalid username to complete rest of user profile
---
 django_airavata/apps/auth/iam_admin_client.py      | 32 ++++++++++++++++++++++
 django_airavata/apps/auth/serializers.py           | 29 ++++++++++++--------
 .../js/components/UserProfileEditor.vue            |  3 +-
 .../js/containers/UserProfileContainer.vue         | 21 +++++++++-----
 django_airavata/apps/auth/views.py                 | 16 ++++++++---
 5 files changed, 77 insertions(+), 24 deletions(-)

diff --git a/django_airavata/apps/auth/iam_admin_client.py b/django_airavata/apps/auth/iam_admin_client.py
index 3d2b29f..6106626 100644
--- a/django_airavata/apps/auth/iam_admin_client.py
+++ b/django_airavata/apps/auth/iam_admin_client.py
@@ -95,3 +95,35 @@ def update_username(username, new_username):
                      json=user,
                      headers=headers)
     r.raise_for_status()
+
+
+def update_user(username, first_name=None, last_name=None, email=None):
+    # fetch user representation
+    authz_token = utils.get_service_account_authz_token()
+    headers = {'Authorization': f'Bearer {authz_token.accessToken}'}
+    parsed = urlparse(settings.KEYCLOAK_AUTHORIZE_URL)
+    r = requests.get(f"{parsed.scheme}://{parsed.netloc}/auth/admin/realms/{settings.GATEWAY_ID}/users",
+                     params={'username': username},
+                     headers=headers)
+    r.raise_for_status()
+    user_list = r.json()
+    user = None
+    # The users search finds partial matches. Loop to find the exact match.
+    for u in user_list:
+        if u['username'] == username:
+            user = u
+            break
+    if user is None:
+        raise Exception(f"Could not find user {username}")
+
+    # update user
+    if first_name is not None:
+        user['firstName'] = first_name
+    if last_name is not None:
+        user['lastName'] = last_name
+    if email is not None:
+        user['email'] = email
+    r = requests.put(f"{parsed.scheme}://{parsed.netloc}/auth/admin/realms/{settings.GATEWAY_ID}/users/{user['id']}",
+                     json=user,
+                     headers=headers)
+    r.raise_for_status()
diff --git a/django_airavata/apps/auth/serializers.py b/django_airavata/apps/auth/serializers.py
index 2eac92a..c10b918 100644
--- a/django_airavata/apps/auth/serializers.py
+++ b/django_airavata/apps/auth/serializers.py
@@ -8,6 +8,8 @@ from django.template import Context
 from django.urls import reverse
 from rest_framework import serializers
 
+from django_airavata.apps.auth import iam_admin_client
+
 from . import models, utils
 
 logger = logging.getLogger(__name__)
@@ -30,6 +32,7 @@ class UserSerializer(serializers.ModelSerializer):
         model = get_user_model()
         fields = ['id', 'username', 'first_name', 'last_name', 'email',
                   'pending_email_change', 'complete', 'username_valid']
+        read_only_fields = ('username',)
 
     def get_pending_email_change(self, instance):
         request = self.context['request']
@@ -61,17 +64,21 @@ class UserSerializer(serializers.ModelSerializer):
         instance.save()
         # save in the user profile service too
         user_profile_client = request.profile_service['user_profile']
-        # Check if user profile exists and create it if not first. User Profile
-        # doesn't get created until the profile is complete, so it may not exist yet.
-        if not user_profile_client.doesUserExist(request.authz_token,
-                                                 request.user.username,
-                                                 settings.GATEWAY_ID):
-            user_profile_client.initializeUserProfile(request.authz_token)
-        airavata_user_profile = user_profile_client.getUserProfileById(
-            request.authz_token, request.user.username, settings.GATEWAY_ID)
-        airavata_user_profile.firstName = instance.first_name
-        airavata_user_profile.lastName = instance.last_name
-        user_profile_client.updateUserProfile(request.authz_token, airavata_user_profile)
+
+        # update the Airavata profile if it exists
+        if user_profile_client.doesUserExist(request.authz_token,
+                                             request.user.username,
+                                             settings.GATEWAY_ID):
+            airavata_user_profile = user_profile_client.getUserProfileById(
+                request.authz_token, request.user.username, settings.GATEWAY_ID)
+            airavata_user_profile.firstName = instance.first_name
+            airavata_user_profile.lastName = instance.last_name
+            user_profile_client.updateUserProfile(request.authz_token, airavata_user_profile)
+        # otherwise, update in Keycloak user store
+        else:
+            iam_admin_client.update_user(request.user.username,
+                                         first_name=instance.first_name,
+                                         last_name=instance.last_name)
         return instance
 
     def _send_email_verification_link(self, request, pending_email_change):
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
index 39447d1..669d356 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
@@ -1,7 +1,6 @@
 <template>
   <b-card>
-    <!-- TODO: add help text that only administrators can change a user's username -->
-    <b-form-group label="Username" :disabled="true">
+    <b-form-group label="Username" :disabled="true" description="Only administrators can update a username.">
       <b-form-input v-model="user.username" />
     </b-form-group>
     <b-form-group label="First Name" :disabled="disabled">
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
index f2ca9cc..647a60a 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
@@ -2,11 +2,16 @@
   <div>
     <h1 class="h4 mb-4">User Profile Editor</h1>
     <b-alert v-if="user && !user.username_valid" show variant="danger">
-      Unfortunately the username on your profile is invalid, which prevents
-      creating or updating your user profile. The administrators have been
-      notified and will be able to update your user account with a valid
-      username. An administrator will notify you once your username has been
-      updated to a valid value.
+      <p>
+        Unfortunately the username on your profile is invalid, which prevents
+        creating or updating your user profile. The administrators have been
+        notified and will be able to update your user account with a valid
+        username. An administrator will notify you once your username has been
+        updated to a valid value.
+      </p>
+      <p>
+        In the meantime, please complete as much of your profile as possible.
+      </p>
     </b-alert>
     <b-alert v-else-if="user && !user.complete" show>
       >Please complete your user profile before continuing.</b-alert
@@ -14,11 +19,13 @@
     <user-profile-editor
       v-if="user"
       v-model="user"
-      :disabled="!user.username_valid"
       @save="onSave"
       @resend-email-verification="resendEmailVerification"
     />
-    <b-link v-if="user && user.complete" class="text-muted small" href="/workspace/dashboard"
+    <b-link
+      v-if="user && user.complete"
+      class="text-muted small"
+      href="/workspace/dashboard"
       >Return to Dashboard</b-link
     >
   </div>
diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py
index df0ac42..edf8861 100644
--- a/django_airavata/apps/auth/views.py
+++ b/django_airavata/apps/auth/views.py
@@ -593,11 +593,19 @@ class UserViewSet(viewsets.ModelViewSet):
         user.refresh_from_db()
 
         try:
+            # only update the airavata profile if it exists
             user_profile_client = request.profile_service['user_profile']
-            airavata_user_profile = user_profile_client.getUserProfileById(
-                request.authz_token, user.username, settings.GATEWAY_ID)
-            airavata_user_profile.emails = [pending_email_change.email_address]
-            user_profile_client.updateUserProfile(request.authz_token, airavata_user_profile)
+            if user_profile_client.doesUserExist(request.authz_token,
+                                                 request.user.username,
+                                                 settings.GATEWAY_ID):
+                airavata_user_profile = user_profile_client.getUserProfileById(
+                    request.authz_token, user.username, settings.GATEWAY_ID)
+                airavata_user_profile.emails = [pending_email_change.email_address]
+                user_profile_client.updateUserProfile(request.authz_token, airavata_user_profile)
+            # otherwise, update the user's email in the Keycloak user store
+            else:
+                iam_admin_client.update_user(request.user.username,
+                                             email=pending_email_change.email_address)
         except Exception as e:
             raise Exception(f"Failed to update Airavata User Profile with new email address: {e}") from e
         serializer = self.get_serializer(user)

[airavata-django-portal] 22/24: AIRAVATA-3319 Fix warning message in change username confirmation

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 3b21298219a73ea60858df67642d06084b356513
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Dec 10 15:20:09 2021 -0500

    AIRAVATA-3319 Fix warning message in change username confirmation
---
 .../src/components/users/ChangeUsernamePanel.vue      | 19 +++++++++++--------
 1 file changed, 11 insertions(+), 8 deletions(-)

diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue
index 930270c..35ef215 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue
@@ -35,18 +35,21 @@
     <confirmation-button
       variant="primary"
       @confirmed="updateUsername"
-      :disabled="$v.$invalid"
+      :disabled="$v.$invalid || username === newUsername"
       dialog-title="Please confirm username change"
     >
       Please confirm that you want to change the user's username to
       <strong>{{ newUsername }}</strong
-      >. NOTE: if this user already has an Airavata User Profile, giving the
-      user a new username will result in
-      <strong
-        >the user getting a new Airavata User Profile and losing the old
-        one</strong
-      >. Also, after updating the username the user will need to log out and log
-      back in.
+      >. After updating the username the user will need to log out and log back
+      in.
+      <b-alert variant="danger" :show="airavataUserProfileExists">
+        This user already has an Airavata User Profile. Giving the user a new
+        username will result in the user getting a new Airavata User Profile and
+        <strong
+          >losing the old one and everything (projects, experiments, etc.)
+          associated with it</strong
+        >.
+      </b-alert>
     </confirmation-button>
   </b-card>
 </template>

[airavata-django-portal] 07/24: AIRAVATA-3468 Allow /media/ in completeness check middleware (gateway logo)

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 06040f9b58def0f91d2f606d203d82cf616b036e
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Wed Jul 7 13:55:59 2021 -0400

    AIRAVATA-3468 Allow /media/ in completeness check middleware (gateway logo)
---
 django_airavata/apps/auth/middleware.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/django_airavata/apps/auth/middleware.py b/django_airavata/apps/auth/middleware.py
index 7921405..78ec89c 100644
--- a/django_airavata/apps/auth/middleware.py
+++ b/django_airavata/apps/auth/middleware.py
@@ -84,9 +84,9 @@ def user_profile_completeness_check(get_response):
             return get_response(request)
 
         if (not request.user.user_profile.is_complete and
-                request.path != reverse('django_airavata_auth:user_profile') and
-                request.path != reverse('django_airavata_auth:logout') and
-                request.META['HTTP_ACCEPT'] != 'application/json'):
+            request.path != reverse('django_airavata_auth:user_profile') and
+            request.path != reverse('django_airavata_auth:logout') and
+                request.META['HTTP_ACCEPT'] != 'application/json') and not request.path.startswith("/media/"):
             return redirect('django_airavata_auth:user_profile')
         else:
             return get_response(request)

[airavata-django-portal] 14/24: AIRAVATA-3319 Alert admins if username isn't valid and provide a means to update it

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 0295ebafb99c646c7bd3667f8f78b349795752fb
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Dec 3 17:46:52 2021 -0500

    AIRAVATA-3319 Alert admins if username isn't valid and provide a means to update it
---
 .../IdentityServiceUserManagementContainer.vue     |   3 +-
 django_airavata/apps/api/serializers.py            |   1 +
 .../django_airavata_api/js/service_config.js       |   2 +-
 django_airavata/apps/api/views.py                  |  18 ++-
 django_airavata/apps/auth/backends.py              |  30 +++-
 django_airavata/apps/auth/middleware.py            |   8 +-
 .../0010_userprofile_username_initialized.py       |  18 +++
 django_airavata/apps/auth/models.py                |   1 +
 django_airavata/apps/auth/tests/test_backends.py   | 177 +++++++++++++++++++++
 django_airavata/apps/auth/utils.py                 |  44 +++++
 10 files changed, 289 insertions(+), 13 deletions(-)

diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/IdentityServiceUserManagementContainer.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/IdentityServiceUserManagementContainer.vue
index 458d718..11a2e24 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/IdentityServiceUserManagementContainer.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/IdentityServiceUserManagementContainer.vue
@@ -207,9 +207,8 @@ export default {
     },
     updateUsername(userProfile, username, newUsername) {
       const updatedUserProfile = userProfile.clone();
-      updatedUserProfile.userId = newUsername;
+      updatedUserProfile.newUsername = newUsername;
       services.IAMUserProfileService.updateUsername({
-        lookup: username,
         data: updatedUserProfile,
       }).finally(() => this.reloadUserProfiles());
     },
diff --git a/django_airavata/apps/api/serializers.py b/django_airavata/apps/api/serializers.py
index 0d49368..fcceaa3 100644
--- a/django_airavata/apps/api/serializers.py
+++ b/django_airavata/apps/api/serializers.py
@@ -952,6 +952,7 @@ class IAMUserProfile(serializers.Serializer):
         lookup_field='userId',
         lookup_url_kwarg='user_id')
     userHasWriteAccess = serializers.SerializerMethodField()
+    newUsername = serializers.CharField(write_only=True)
 
     def update(self, instance, validated_data):
         existing_group_ids = [group.id for group in instance['groups']]
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/service_config.js b/django_airavata/apps/api/static/django_airavata_api/js/service_config.js
index d3c82b0..152a396 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/service_config.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/service_config.js
@@ -288,7 +288,7 @@ export default {
         modelClass: IAMUserProfile,
       },
       updateUsername: {
-        url: "/api/iam-user-profiles/<lookup>/update_username/",
+        url: "/api/iam-user-profiles/update_username/",
         bodyParams: {
           name: "data",
         },
diff --git a/django_airavata/apps/api/views.py b/django_airavata/apps/api/views.py
index ded1201..b9b68fd 100644
--- a/django_airavata/apps/api/views.py
+++ b/django_airavata/apps/api/views.py
@@ -25,6 +25,7 @@ from airavata.model.group.ttypes import ResourcePermissionType
 from airavata.model.user.ttypes import Status
 from airavata_django_portal_sdk import experiment_util, user_storage
 from django.conf import settings
+from django.contrib.auth import get_user_model
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.http import Http404, HttpResponse, JsonResponse
 from django.shortcuts import redirect
@@ -1597,13 +1598,22 @@ class IAMUserViewSet(mixins.RetrieveModelMixin,
                                            context={'request': request})
         return Response(serializer.data)
 
-    @action(methods=['put'], detail=True)
-    def update_username(self, request, user_id=None):
+    @action(methods=['put'], detail=False)
+    def update_username(self, request):
         serializer = self.get_serializer(data=request.data)
         serializer.is_valid(raise_exception=True)
-        old_username = user_id
-        new_username = serializer.validated_data['userId']
+        old_username = serializer.validated_data['userId']
+        new_username = serializer.validated_data['newUsername']
         iam_admin_client.update_username(old_username, new_username)
+        # set username_initialized to True so it is treated as valid.
+        django_user = get_user_model().objects.get(username=old_username)
+        django_user.user_profile.username_initialized = True
+        django_user.user_profile.save()
+        # Not strictly necessary since next time the user logs in, the Django
+        # user record for the user will get updated to have the new username.
+        # But this is done to keep it consistent.
+        django_user.username = new_username
+        django_user.save()
         instance = self.get_instance(new_username)
         serializer = self.serializer_class(instance=instance,
                                            context={'request': request})
diff --git a/django_airavata/apps/auth/backends.py b/django_airavata/apps/auth/backends.py
index a60fd59..2c41bf5 100644
--- a/django_airavata/apps/auth/backends.py
+++ b/django_airavata/apps/auth/backends.py
@@ -78,7 +78,7 @@ class KeycloakBackend(object):
                 user = self._process_userinfo(request, userinfo)
                 if idp_alias is not None:
                     self._store_idp_userinfo(user, token, idp_alias)
-                # TODO: if idp_alias, add idp userinfo too
+                    self._check_username_initialization(request, user)
                 access_token = token['access_token']
             # authz_token_middleware has already run, so must manually add
             # the `request.authz_token` attribute
@@ -216,9 +216,9 @@ class KeycloakBackend(object):
         logger.debug("userinfo: {}".format(userinfo))
         sub = userinfo['sub']
         username = userinfo['preferred_username']
-        email = userinfo['email']
-        first_name = userinfo['given_name']
-        last_name = userinfo['family_name']
+        email = userinfo.get('email', '')
+        first_name = userinfo.get('given_name', None)
+        last_name = userinfo.get('family_name', None)
 
         user = self._get_or_create_user(sub, username)
         user_profile = user.user_profile
@@ -323,3 +323,25 @@ class KeycloakBackend(object):
                     user_profile.idp_userinfo.create(idp_alias=idp_alias, claim=claim, value=value)
         except Exception:
             logger.exception(f"Failed to store IDP userinfo for {user.username} from IDP {idp_alias}")
+
+    def _check_username_initialization(self, request, user):
+        # Check if the username assigned to the user was based on the user's
+        # email address or if it was assigned some random string (Keycloak's
+        # sub). If the latter, we'll want to alert the admins so that they can
+        # assign a proper username for the user.
+        user_profile = user.user_profile
+        if (not user_profile.username_initialized and
+            user_profile.userinfo_set.filter(claim='email').exists() and
+            user_profile.userinfo_set.filter(claim='preferred_username').exists() and
+                user_profile.userinfo_set.get(claim='email').value == user_profile.userinfo_set.get(claim='preferred_username').value):
+            user_profile.username_initialized = True
+            user_profile.save()
+
+        # TODO: also check idp_userinfo.preferred_username if it exists
+
+        if not user_profile.username_initialized:
+            try:
+                utils.send_admin_alert_about_uninitialized_username(
+                    request, user.username, user.email, user.first_name, user.last_name)
+            except Exception:
+                logger.exception(f"Failed to send alert about username being uninitialized: {user.username}")
diff --git a/django_airavata/apps/auth/middleware.py b/django_airavata/apps/auth/middleware.py
index ba49ec3..503cec6 100644
--- a/django_airavata/apps/auth/middleware.py
+++ b/django_airavata/apps/auth/middleware.py
@@ -38,7 +38,10 @@ def gateway_groups_middleware(get_response):
         request.is_gateway_admin = False
         request.is_read_only_gateway_admin = False
 
-        if not request.user.is_authenticated or not request.authz_token or not request.user.user_profile.is_complete:
+        if (not request.user.is_authenticated or
+            not request.authz_token or
+            (hasattr(request.user, "user_profile") and
+                not request.user.user_profile.is_complete)):
             return get_response(request)
 
         try:
@@ -87,7 +90,8 @@ def user_profile_completeness_check(get_response):
             reverse('django_airavata_auth:user_profile'),
             reverse('django_airavata_auth:logout'),
         ]
-        if (not request.user.user_profile.is_complete and
+        if (hasattr(request.user, "user_profile") and
+            not request.user.user_profile.is_complete and
             request.path not in allowed_paths and
                 'text/html' in request.META['HTTP_ACCEPT']):
             return redirect('django_airavata_auth:user_profile')
diff --git a/django_airavata/apps/auth/migrations/0010_userprofile_username_initialized.py b/django_airavata/apps/auth/migrations/0010_userprofile_username_initialized.py
new file mode 100644
index 0000000..4b47459
--- /dev/null
+++ b/django_airavata/apps/auth/migrations/0010_userprofile_username_initialized.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.8 on 2021-10-12 20:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('django_airavata_auth', '0009_auto_20210625_1725'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='userprofile',
+            name='username_initialized',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index 726efce..8296311 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -58,6 +58,7 @@ class UserProfile(models.Model):
     # TODO: maybe this can be derived from whether there exists an Airavata
     # User Profile for the user's username
     username_locked = models.BooleanField(default=False)
+    username_initialized = models.BooleanField(default=False)
 
     @property
     def is_complete(self):
diff --git a/django_airavata/apps/auth/tests/test_backends.py b/django_airavata/apps/auth/tests/test_backends.py
new file mode 100644
index 0000000..bd696ac
--- /dev/null
+++ b/django_airavata/apps/auth/tests/test_backends.py
@@ -0,0 +1,177 @@
+from unittest.mock import MagicMock, patch
+
+from django.contrib.auth.models import AnonymousUser
+from django.core import mail
+from django.test import RequestFactory, TestCase, override_settings
+
+from django_airavata.apps.auth import backends
+
+KEYCLOAK_CLIENT_ID = "kc-client"
+KEYCLOAK_CLIENT_SECRET = "kc-secret"
+KEYCLOAK_TOKEN_URL = "https://example.org/auth"
+KEYCLOAK_USERINFO_URL = "https://example.org/userinfo"
+KEYCLOAK_VERIFY_SSL = True
+AUTHENTICATION_OPTIONS = {
+    'external': [
+        {
+            'idp_alias': 'oidc',
+            'name': 'Some OIDC compliant IDP',
+        }
+    ]
+}
+GATEWAY_ID = "gateway-id"
+
+
+@override_settings(
+    KEYCLOAK_CLIENT_ID=KEYCLOAK_CLIENT_ID,
+    KEYCLOAK_CLIENT_SECRET=KEYCLOAK_CLIENT_SECRET,
+    KEYCLOAK_TOKEN_URL=KEYCLOAK_TOKEN_URL,
+    KEYCLOAK_USERINFO_URL=KEYCLOAK_USERINFO_URL,
+    KEYCLOAK_VERIFY_SSL=KEYCLOAK_VERIFY_SSL,
+    AUTHENTICATION_OPTIONS=AUTHENTICATION_OPTIONS,
+    GATEWAY_ID=GATEWAY_ID,
+)
+class KeycloakBackendTestCase(TestCase):
+
+    def setUp(self):
+        self.factory = RequestFactory()
+
+    @patch("django_airavata.apps.auth.backends.OAuth2Session")
+    def test_username_initialized_with_email(self, MockOAuth2Session):
+        """Test that username_initialized is set to True when username equals email address."""
+
+        # Tests scenario that new user logs in via external IDP and when they
+        # are assigned a username it is the same as their email address. This is
+        # normally what happens and is a good outcome for the username so
+        # username_initialized should be set to True and no alert email should
+        # be sent to admins.
+
+        # Mock out request for redirect flow, and OAuth2Session: token and userinfo
+        request = self.factory.get("/callback?code=abc123", secure=True)
+        request.user = AnonymousUser()
+        request.session = {
+            'OAUTH2_STATE': 'state',
+            'OAUTH2_REDIRECT_URI': 'redirect-uri',
+        }
+        mock_oauth2_session = MagicMock()
+        MockOAuth2Session.return_value = mock_oauth2_session
+        mock_oauth2_session.fetch_token.return_value = {
+            'access_token': 'the-access-token',
+            'expires_in': 900,
+            'refresh_token': 'the-refresh-token',
+            'refresh_expires_in': 86400,
+        }
+        mock_userinfo = MagicMock()
+        mock_oauth2_session.get.return_value = mock_userinfo
+        email = 'testuser@example.org'
+        mock_userinfo.json.return_value = {
+            'sub': 'sub-123',
+            'preferred_username': email,
+            'email': email,
+            'given_name': 'Test',
+            'family_name': 'User',
+        }
+
+        # Mock out fetching IDP userinfo: AUTHENTICATION_OPTIONS: request to
+        # idp_token_url and userinfo_url
+
+        backend = backends.KeycloakBackend()
+        idp_alias = "oidc"
+        user = backend.authenticate(request, idp_alias=idp_alias)
+
+        self.assertTrue(user.user_profile.username_initialized)
+        self.assertEqual(0, len(mail.outbox))
+
+    @patch("django_airavata.apps.auth.backends.OAuth2Session")
+    def test_username_initialized_with_no_email(self, MockOAuth2Session):
+        """Test that username_initialized is set to False when there is no email address."""
+
+        # Tests scenario that new user logs in via external IDP and when they
+        # are assigned a username but don't have an email address. This usually
+        # means that they also have a randomly generated username and admins
+        # need to be alerted.
+
+        # Mock out request for redirect flow, and OAuth2Session: token and userinfo
+        request = self.factory.get("/callback?code=abc123", secure=True)
+        request.user = AnonymousUser()
+        request.session = {
+            'OAUTH2_STATE': 'state',
+            'OAUTH2_REDIRECT_URI': 'redirect-uri',
+        }
+        mock_oauth2_session = MagicMock()
+        MockOAuth2Session.return_value = mock_oauth2_session
+        mock_oauth2_session.fetch_token.return_value = {
+            'access_token': 'the-access-token',
+            'expires_in': 900,
+            'refresh_token': 'the-refresh-token',
+            'refresh_expires_in': 86400,
+        }
+        mock_userinfo = MagicMock()
+        mock_oauth2_session.get.return_value = mock_userinfo
+        # email = 'testuser@example.org'
+        mock_userinfo.json.return_value = {
+            'sub': 'sub-123',
+            'preferred_username': 'some-random-username',
+            # 'email': email,
+            'given_name': 'Test',
+            'family_name': 'User',
+        }
+
+        # Mock out fetching IDP userinfo: AUTHENTICATION_OPTIONS: request to
+        # idp_token_url and userinfo_url
+
+        backend = backends.KeycloakBackend()
+        idp_alias = "oidc"
+        user = backend.authenticate(request, idp_alias=idp_alias)
+
+        self.assertFalse(user.user_profile.userinfo_set.filter(claim='email').exists())
+        self.assertFalse(user.user_profile.username_initialized)
+        self.assertEqual(1, len(mail.outbox))
+        self.assertTrue(mail.outbox[0].subject.startswith("Please fix username"))
+
+    @patch("django_airavata.apps.auth.backends.OAuth2Session")
+    def test_username_initialized_with_email_not_username(self, MockOAuth2Session):
+        """Test that username_initialized is set to False when email address is different from username."""
+
+        # Tests scenario that new user logs in via external IDP and when they
+        # are assigned a username but it's different from their email address.
+        # This usually means that they also have a randomly generated username
+        # and admins need to be alerted.
+
+        # Mock out request for redirect flow, and OAuth2Session: token and userinfo
+        request = self.factory.get("/callback?code=abc123", secure=True)
+        request.user = AnonymousUser()
+        request.session = {
+            'OAUTH2_STATE': 'state',
+            'OAUTH2_REDIRECT_URI': 'redirect-uri',
+        }
+        mock_oauth2_session = MagicMock()
+        MockOAuth2Session.return_value = mock_oauth2_session
+        mock_oauth2_session.fetch_token.return_value = {
+            'access_token': 'the-access-token',
+            'expires_in': 900,
+            'refresh_token': 'the-refresh-token',
+            'refresh_expires_in': 86400,
+        }
+        mock_userinfo = MagicMock()
+        mock_oauth2_session.get.return_value = mock_userinfo
+        email = 'testuser@example.org'
+        mock_userinfo.json.return_value = {
+            'sub': 'sub-123',
+            'preferred_username': 'some-random-username',
+            'email': email,
+            'given_name': 'Test',
+            'family_name': 'User',
+        }
+
+        # Mock out fetching IDP userinfo: AUTHENTICATION_OPTIONS: request to
+        # idp_token_url and userinfo_url
+
+        backend = backends.KeycloakBackend()
+        idp_alias = "oidc"
+        user = backend.authenticate(request, idp_alias=idp_alias)
+
+        self.assertTrue(user.user_profile.userinfo_set.filter(claim='email').exists())
+        self.assertFalse(user.user_profile.username_initialized)
+        self.assertEqual(1, len(mail.outbox))
+        self.assertTrue(mail.outbox[0].subject.startswith("Please fix username"))
diff --git a/django_airavata/apps/auth/utils.py b/django_airavata/apps/auth/utils.py
index 6da77ea..d7e714c 100644
--- a/django_airavata/apps/auth/utils.py
+++ b/django_airavata/apps/auth/utils.py
@@ -184,6 +184,50 @@ def send_admin_alert_about_invalid_username(request, username, email, first_name
     msg.send()
 
 
+def send_admin_alert_about_uninitialized_username(request, username, email, first_name, last_name):
+    domain, port = split_domain_port(request.get_host())
+    context = Context({
+        "username": username,
+        "email": email,
+        "first_name": first_name,
+        "last_name": last_name,
+        "portal_title": settings.PORTAL_TITLE,
+        "gateway_id": settings.GATEWAY_ID,
+        "http_host": domain,
+    })
+    subject = Template("Please fix username: a user of {{portal_title}} ({{http_host}}) has been assigned a random username ({{username}})").render(context)
+    body = Template("""
+    <p>
+    Dear Admin,
+    </p>
+
+    <p>
+    The following user has a random username because the system could not
+    determine a proper username:
+    </p>
+
+    <p>Username: {{username}}</p>
+    <p>Name: {{first_name}} {{last_name}}</p>
+    <p>Email: {{email}}</p>
+
+    <p>
+    This likely happened because there was no appropriate user attribute to use
+    for the user's username when the user logged in through an external identity
+    provider.  Please update the username to the user's email address or some
+    other appropriate value in the <a href="https://{{http_host}}/admin/users/">Manage
+    Users</a> view in the portal.
+    </p>
+    """.strip()).render(context)
+    msg = EmailMessage(subject=subject,
+                       body=body,
+                       from_email=f'"{settings.PORTAL_TITLE}" <{settings.SERVER_EMAIL}>',
+                       to=[f'"{a[0]}" <{a[1]}>' for a in getattr(settings,
+                                                                 'PORTAL_ADMINS',
+                                                                 settings.ADMINS)])
+    msg.content_subtype = 'html'
+    msg.send()
+
+
 def send_email_to_user(template_id, context):
     email_template = models.EmailTemplate.objects.get(pk=template_id)
     subject = Template(email_template.subject).render(context)

[airavata-django-portal] 05/24: AIRAVATA-3319 Add admin UI for updating user's username

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 683f1fd5724386fa5673715631ee4af2fe9bca3c
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Tue Jul 6 12:55:54 2021 -0400

    AIRAVATA-3319 Add admin UI for updating user's username
---
 django_airavata/apps/admin/package.json            |  1 +
 .../src/components/users/ChangeUsernamePanel.vue   | 92 ++++++++++++++++++++++
 .../IdentityServiceUserManagementContainer.vue     |  9 +++
 .../src/components/users/UserDetailsContainer.vue  |  7 ++
 django_airavata/apps/admin/yarn.lock               |  5 ++
 .../django_airavata_api/js/service_config.js       |  8 ++
 .../common/js/components/ConfirmationButton.vue    | 52 ++++++++++++
 django_airavata/static/common/js/index.js          |  2 +
 8 files changed, 176 insertions(+)

diff --git a/django_airavata/apps/admin/package.json b/django_airavata/apps/admin/package.json
index 670f5f5..94fde2f 100644
--- a/django_airavata/apps/admin/package.json
+++ b/django_airavata/apps/admin/package.json
@@ -26,6 +26,7 @@
     "vue-resource": "^1.3.4",
     "vue-router": "^2.7.0",
     "vuedraggable": "^2.16.0",
+    "vuelidate": "^0.7.6",
     "vuex": "^2.4.0",
     "weekstart": "^1.0.0"
   },
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue
new file mode 100644
index 0000000..42bc64b
--- /dev/null
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue
@@ -0,0 +1,92 @@
+<template>
+  <b-card header="Change Username">
+    <p class="card-text">
+      This will change the user's username in the identity service. NOTE: if
+      this user already has an Airavata User Profile, giving the user a new
+      username will result in the user getting a new Airavata User Profile and
+      losing the old one. Also, after updating the username the user will need
+      to log out and log back in.
+    </p>
+    <b-form-group label="New Username" label-for="new-username">
+      <b-input-group>
+        <b-form-input
+          id="new-username"
+          v-model="$v.newUsername.$model"
+          :state="validateState($v.newUsername)"
+        />
+        <b-input-group-append>
+          <b-button @click="newUsername = email">Copy Email Address</b-button>
+        </b-input-group-append>
+      </b-input-group>
+      <b-form-invalid-feedback
+        :state="validateState($v.newUsername)"
+        v-if="!$v.newUsername.emailOrMatchesRegex"
+      >
+        Username can only contain lowercase letters, numbers, underscores and
+        hyphens OR it can be the same as the email address.
+      </b-form-invalid-feedback>
+    </b-form-group>
+    <confirmation-button
+      variant="primary"
+      @confirmed="updateUsername"
+      :disabled="$v.$invalid"
+      dialog-title="Please confirm username change"
+    >
+      Please confirm that you want to change the user's username to
+      <strong>{{ newUsername }}</strong
+      >. NOTE: if this user already has an Airavata User Profile, giving the
+      user a new username will result in
+      <strong
+        >the user getting a new Airavata User Profile and losing the old
+        one</strong
+      >. Also, after updating the username the user will need to log out and log
+      back in.
+    </confirmation-button>
+  </b-card>
+</template>
+
+<script>
+import { components, errors } from "django-airavata-common-ui";
+import { validationMixin } from "vuelidate";
+import { helpers, or, required, sameAs } from "vuelidate/lib/validators";
+export default {
+  name: "change-username-panel",
+  mixins: [validationMixin],
+  props: {
+    username: {
+      type: String,
+      required: true,
+    },
+    email: {
+      type: String,
+      required: true,
+    },
+  },
+  components: {
+    "confirmation-button": components.ConfirmationButton,
+  },
+  data() {
+    return {
+      newUsername: this.username,
+    };
+  },
+  validations() {
+    const usernameRegex = helpers.regex("newUsername", /^[a-z0-9_-]+$/);
+    const emailOrMatchesRegex = or(usernameRegex, sameAs("email"));
+    return {
+      newUsername: {
+        required,
+        emailOrMatchesRegex,
+      },
+    };
+  },
+  methods: {
+    updateUsername() {
+      if (!this.$v.$invalid) {
+        this.$emit("update-username", [this.username, this.newUsername]);
+      }
+    },
+    validateState: errors.vuelidateHelpers.validateState,
+  },
+};
+</script>
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/IdentityServiceUserManagementContainer.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/IdentityServiceUserManagementContainer.vue
index f7785da..458d718 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/IdentityServiceUserManagementContainer.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/IdentityServiceUserManagementContainer.vue
@@ -47,6 +47,7 @@
                   @groups-updated="groupsUpdated"
                   @enable-user="enableUser"
                   @delete-user="deleteUser"
+                  @update-username="updateUsername(data.item, ...$event)"
                 />
               </template>
             </b-table>
@@ -204,6 +205,14 @@ export default {
         this.reloadUserProfiles()
       );
     },
+    updateUsername(userProfile, username, newUsername) {
+      const updatedUserProfile = userProfile.clone();
+      updatedUserProfile.userId = newUsername;
+      services.IAMUserProfileService.updateUsername({
+        lookup: username,
+        data: updatedUserProfile,
+      }).finally(() => this.reloadUserProfiles());
+    },
   },
 };
 </script>
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
index 48b9516..e59428f 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
@@ -34,6 +34,11 @@
       :username="iamUserProfile.userId"
       @delete-user="$emit('delete-user', $event)"
     />
+    <change-username-panel
+      :username="iamUserProfile.userId"
+      :email="iamUserProfile.email"
+      @update-username="$emit('update-username', $event)"
+    />
   </div>
 </template>
 <script>
@@ -42,6 +47,7 @@ import UserGroupMembershipEditor from "./UserGroupMembershipEditor";
 import ActivateUserPanel from "./ActivateUserPanel";
 import EnableUserPanel from "./EnableUserPanel";
 import DeleteUserPanel from "./DeleteUserPanel";
+import ChangeUsernamePanel from "./ChangeUsernamePanel.vue";
 
 export default {
   name: "user-details-container",
@@ -60,6 +66,7 @@ export default {
     EnableUserPanel,
     DeleteUserPanel,
     ActivateUserPanel,
+    ChangeUsernamePanel,
   },
   data() {
     return {
diff --git a/django_airavata/apps/admin/yarn.lock b/django_airavata/apps/admin/yarn.lock
index 1e34aef..8b1d3b2 100644
--- a/django_airavata/apps/admin/yarn.lock
+++ b/django_airavata/apps/admin/yarn.lock
@@ -8574,6 +8574,11 @@ vuedraggable@^2.16.0:
   dependencies:
     sortablejs "^1.10.1"
 
+vuelidate@^0.7.6:
+  version "0.7.6"
+  resolved "https://registry.yarnpkg.com/vuelidate/-/vuelidate-0.7.6.tgz#84100c13b943470660d0416642845cd2a1edf4b2"
+  integrity sha512-suzIuet1jGcyZ4oUSW8J27R2tNrJ9cIfklAh63EbAkFjE380iv97BAiIeolRYoB9bF9usBXCu4BxftWN1Dkn3g==
+
 vuex@^2.4.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/vuex/-/vuex-2.5.0.tgz#20f0265ade6c9a5ac6724a405d3ffdb4726c9741"
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/service_config.js b/django_airavata/apps/api/static/django_airavata_api/js/service_config.js
index 64ad6b1..d3c82b0 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/service_config.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/service_config.js
@@ -287,6 +287,14 @@ export default {
         requestType: "post",
         modelClass: IAMUserProfile,
       },
+      updateUsername: {
+        url: "/api/iam-user-profiles/<lookup>/update_username/",
+        bodyParams: {
+          name: "data",
+        },
+        requestType: "put",
+        modelClass: IAMUserProfile,
+      },
     },
     queryParams: ["limit", "offset", "search"],
     modelClass: IAMUserProfile,
diff --git a/django_airavata/static/common/js/components/ConfirmationButton.vue b/django_airavata/static/common/js/components/ConfirmationButton.vue
new file mode 100644
index 0000000..c327045
--- /dev/null
+++ b/django_airavata/static/common/js/components/ConfirmationButton.vue
@@ -0,0 +1,52 @@
+<template>
+  <div class="confirmation-button">
+    <b-button
+      :variant="variant"
+      @click="$refs.modal.show()"
+      :disabled="disabled"
+    >
+      {{ label }}
+    </b-button>
+    <confirmation-dialog
+      ref="modal"
+      :title="dialogTitle"
+      @ok="$emit('confirmed')"
+    >
+      <slot></slot>
+    </confirmation-dialog>
+  </div>
+</template>
+<script>
+import ConfirmationDialog from "./ConfirmationDialog.vue";
+
+export default {
+  name: "confirmation-button",
+  props: {
+    dialogTitle: {
+      type: String,
+      default: "Please confirm",
+    },
+    disabled: {
+      type: Boolean,
+      default: false,
+    },
+    label: {
+      type: String,
+      default: "Update",
+    },
+    variant: {
+      type: String,
+      default: "danger",
+    },
+  },
+  components: {
+    ConfirmationDialog,
+  },
+};
+</script>
+
+<style scoped>
+.confirmation-button {
+  display: inline-block;
+}
+</style>
diff --git a/django_airavata/static/common/js/index.js b/django_airavata/static/common/js/index.js
index ac905a9..b4bcc5e 100644
--- a/django_airavata/static/common/js/index.js
+++ b/django_airavata/static/common/js/index.js
@@ -4,6 +4,7 @@ import AutocompleteTextInput from "./components/AutocompleteTextInput.vue";
 import ClipboardCopyButton from "./components/ClipboardCopyButton.vue";
 import ClipboardCopyLink from "./components/ClipboardCopyLink.vue";
 import ComputeResourceName from "./components/ComputeResourceName";
+import ConfirmationButton from "./components/ConfirmationButton.vue";
 import ConfirmationDialog from "./components/ConfirmationDialog.vue";
 import DataProductViewer from "./components/DataProductViewer";
 import DeleteButton from "./components/DeleteButton.vue";
@@ -47,6 +48,7 @@ const components = {
   ClipboardCopyButton,
   ClipboardCopyLink,
   ComputeResourceName,
+  ConfirmationButton,
   ConfirmationDialog,
   DataProductViewer,
   DeleteButton,

[airavata-django-portal] 23/24: AIRAVATA-3319 merge migration

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 66393d5bd7fa7afa1747090b275927e17f7d359e
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Dec 10 15:42:05 2021 -0500

    AIRAVATA-3319 merge migration
---
 .../apps/auth/migrations/0012_merge_20211210_2041.py       | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/django_airavata/apps/auth/migrations/0012_merge_20211210_2041.py b/django_airavata/apps/auth/migrations/0012_merge_20211210_2041.py
new file mode 100644
index 0000000..042a9a0
--- /dev/null
+++ b/django_airavata/apps/auth/migrations/0012_merge_20211210_2041.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.2.8 on 2021-12-10 20:41
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('django_airavata_auth', '0009_alter_emailverification_next'),
+        ('django_airavata_auth', '0011_remove_userprofile_username_locked'),
+    ]
+
+    operations = [
+    ]

[airavata-django-portal] 19/24: AIRAVATA-3319 Reorganized manage users detail into tabs

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit f383637d8b8b8c2576c22c51d0aca3043342dd2b
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Thu Dec 9 12:46:14 2021 -0500

    AIRAVATA-3319 Reorganized manage users detail into tabs
---
 .../src/components/users/ActivateUserPanel.vue     |   4 +-
 .../src/components/users/ChangeUsernamePanel.vue   |  20 +++-
 .../src/components/users/EditGroupsPanel.vue       |  68 +++++++++++++
 .../src/components/users/UserDetailsContainer.vue  | 111 +++++++++------------
 4 files changed, 131 insertions(+), 72 deletions(-)

diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ActivateUserPanel.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ActivateUserPanel.vue
index 0bbc9db..4868a57 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ActivateUserPanel.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ActivateUserPanel.vue
@@ -1,8 +1,8 @@
 <template>
   <b-card header="Activate User">
     <p class="card-text">
-      User {{ username }} has verified their login, but doesn't yet have an
-      Airavata User Profile. Click <b>Activate</b>
+      User {{ username }} has verified their email address, but doesn't yet have
+      an Airavata User Profile. Click <b>Activate</b>
       to create an Airavata User Profile for this user. This will allow the user
       to be assigned to groups.
     </p>
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue
index 42bc64b..930270c 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ChangeUsernamePanel.vue
@@ -1,12 +1,18 @@
 <template>
   <b-card header="Change Username">
     <p class="card-text">
-      This will change the user's username in the identity service. NOTE: if
-      this user already has an Airavata User Profile, giving the user a new
-      username will result in the user getting a new Airavata User Profile and
-      losing the old one. Also, after updating the username the user will need
-      to log out and log back in.
+      This will change the user's username in the identity service. Typically,
+      you would only change the user's username when they login through an
+      external identity provider and are automatically assigned an invalid
+      username. Also, after updating the username the user will need to log out
+      and log back in.
     </p>
+    <b-alert variant="warning" :show="airavataUserProfileExists">
+      This user already has an Airavata User Profile. Giving the user a new
+      username will result in the user getting a new Airavata User Profile and
+      losing the old one and everything (projects, experiments, etc.) associated
+      with it.
+    </b-alert>
     <b-form-group label="New Username" label-for="new-username">
       <b-input-group>
         <b-form-input
@@ -61,6 +67,10 @@ export default {
       type: String,
       required: true,
     },
+    airavataUserProfileExists: {
+      type: Boolean,
+      default: false,
+    },
   },
   components: {
     "confirmation-button": components.ConfirmationButton,
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/EditGroupsPanel.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/EditGroupsPanel.vue
new file mode 100644
index 0000000..953d00f
--- /dev/null
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/EditGroupsPanel.vue
@@ -0,0 +1,68 @@
+<template>
+  <b-card header="Edit Groups">
+    <user-group-membership-editor
+      v-model="data"
+      :editable-groups="editableGroups"
+      :airavata-internal-user-id="airavataInternalUserId"
+    />
+    <b-button
+      @click="$emit('save', data)"
+      variant="primary"
+      :disabled="!areGroupsUpdated"
+      >Save</b-button
+    >
+  </b-card>
+</template>
+
+<script>
+import VModelMixin from "django-airavata-common-ui/js/mixins/VModelMixin";
+import UserGroupMembershipEditor from "./UserGroupMembershipEditor.vue";
+
+export default {
+  components: { UserGroupMembershipEditor },
+  props: {
+    value: {
+      type: Array,
+      required: true,
+    },
+    editableGroups: {
+      type: Array,
+      required: true,
+    },
+    airavataInternalUserId: {
+      type: String,
+      required: true,
+    },
+  },
+  mixins: [VModelMixin],
+  computed: {
+    currentGroupIds() {
+      const groupIds = this.value.map((g) => g.id);
+      groupIds.sort();
+      return groupIds;
+    },
+    updatedGroupIds() {
+      const groupIds = this.data.map((g) => g.id);
+      groupIds.sort();
+      return groupIds;
+    },
+    areGroupsUpdated() {
+      for (const groupId of this.currentGroupIds) {
+        // Check if a group was removed
+        if (this.updatedGroupIds.indexOf(groupId) < 0) {
+          return true;
+        }
+      }
+      for (const groupId of this.updatedGroupIds) {
+        // Check if a group was added
+        if (this.currentGroupIds.indexOf(groupId) < 0) {
+          return true;
+        }
+      }
+      return false;
+    },
+  },
+};
+</script>
+
+<style></style>
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
index e59428f..f78dca1 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
@@ -1,45 +1,49 @@
 <template>
-  <div>
-    <b-card header="Edit Groups">
-      <user-group-membership-editor
+  <b-tabs content-class="mt-3 px-2">
+    <b-tab
+      title="User Profile"
+      :active="iamUserProfile.airavataUserProfileExists"
+    >
+      <edit-groups-panel
         v-if="iamUserProfile.airavataUserProfileExists"
-        v-model="localIAMUserProfile.groups"
+        :value="localIAMUserProfile.groups"
         :editable-groups="editableGroups"
         :airavata-internal-user-id="iamUserProfile.airavataInternalUserId"
+        @save="groupsUpdated"
       />
-      <b-button
-        @click="groupsUpdated"
-        variant="primary"
-        :disabled="!areGroupsUpdated"
-        >Save</b-button
-      >
-    </b-card>
-    <activate-user-panel
-      v-if="
-        iamUserProfile.enabled &&
-        iamUserProfile.emailVerified &&
-        !iamUserProfile.airavataUserProfileExists
-      "
-      :username="iamUserProfile.userId"
-      @activate-user="$emit('enable-user', $event)"
-    />
-    <enable-user-panel
-      v-if="!iamUserProfile.enabled && !iamUserProfile.emailVerified"
-      :username="iamUserProfile.userId"
-      :email="iamUserProfile.email"
-      @enable-user="$emit('enable-user', $event)"
-    />
-    <delete-user-panel
-      v-if="!iamUserProfile.enabled && !iamUserProfile.emailVerified"
-      :username="iamUserProfile.userId"
-      @delete-user="$emit('delete-user', $event)"
-    />
-    <change-username-panel
-      :username="iamUserProfile.userId"
-      :email="iamUserProfile.email"
-      @update-username="$emit('update-username', $event)"
-    />
-  </div>
+    </b-tab>
+    <b-tab
+      title="Troubleshooting"
+      :active="!iamUserProfile.airavataUserProfileExists"
+    >
+      <activate-user-panel
+        v-if="
+          iamUserProfile.enabled &&
+          iamUserProfile.emailVerified &&
+          !iamUserProfile.airavataUserProfileExists
+        "
+        :username="iamUserProfile.userId"
+        @activate-user="$emit('enable-user', $event)"
+      />
+      <enable-user-panel
+        v-if="!iamUserProfile.enabled && !iamUserProfile.emailVerified"
+        :username="iamUserProfile.userId"
+        :email="iamUserProfile.email"
+        @enable-user="$emit('enable-user', $event)"
+      />
+      <delete-user-panel
+        v-if="!iamUserProfile.enabled && !iamUserProfile.emailVerified"
+        :username="iamUserProfile.userId"
+        @delete-user="$emit('delete-user', $event)"
+      />
+      <change-username-panel
+        :username="iamUserProfile.userId"
+        :email="iamUserProfile.email"
+        :airavata-user-profile-exists="iamUserProfile.airavataUserProfileExists"
+        @update-username="$emit('update-username', $event)"
+      />
+    </b-tab>
+  </b-tabs>
 </template>
 <script>
 import { models } from "django-airavata-api";
@@ -48,6 +52,7 @@ import ActivateUserPanel from "./ActivateUserPanel";
 import EnableUserPanel from "./EnableUserPanel";
 import DeleteUserPanel from "./DeleteUserPanel";
 import ChangeUsernamePanel from "./ChangeUsernamePanel.vue";
+import EditGroupsPanel from "./EditGroupsPanel.vue";
 
 export default {
   name: "user-details-container",
@@ -67,6 +72,7 @@ export default {
     DeleteUserPanel,
     ActivateUserPanel,
     ChangeUsernamePanel,
+    EditGroupsPanel,
   },
   data() {
     return {
@@ -79,36 +85,11 @@ export default {
     },
   },
   methods: {
-    groupsUpdated() {
+    groupsUpdated(groups) {
+      this.localIAMUserProfile.groups = groups;
       this.$emit("groups-updated", this.localIAMUserProfile);
     },
   },
-  computed: {
-    currentGroupIds() {
-      const groupIds = this.iamUserProfile.groups.map((g) => g.id);
-      groupIds.sort();
-      return groupIds;
-    },
-    updatedGroupIds() {
-      const groupIds = this.localIAMUserProfile.groups.map((g) => g.id);
-      groupIds.sort();
-      return groupIds;
-    },
-    areGroupsUpdated() {
-      for (const groupId of this.currentGroupIds) {
-        // Check if a group was removed
-        if (this.updatedGroupIds.indexOf(groupId) < 0) {
-          return true;
-        }
-      }
-      for (const groupId of this.updatedGroupIds) {
-        // Check if a group was added
-        if (this.currentGroupIds.indexOf(groupId) < 0) {
-          return true;
-        }
-      }
-      return false;
-    },
-  },
+  computed: {},
 };
 </script>

[airavata-django-portal] 03/24: AIRAVATA-3468 configuration of URLs for retrieving external IDP userinfo

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 0dc515fe64d3490cf6278c327044ee0de6b83f20
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Jun 25 13:50:16 2021 -0400

    AIRAVATA-3468 configuration of URLs for retrieving external IDP userinfo
---
 django_airavata/apps/auth/backends.py | 15 +++++++++++++--
 1 file changed, 13 insertions(+), 2 deletions(-)

diff --git a/django_airavata/apps/auth/backends.py b/django_airavata/apps/auth/backends.py
index 7202ec7..2197928 100644
--- a/django_airavata/apps/auth/backends.py
+++ b/django_airavata/apps/auth/backends.py
@@ -277,6 +277,17 @@ class KeycloakBackend(object):
 
     def _store_idp_userinfo(self, user, token, idp_alias):
         try:
+            idp_token_url = None
+            userinfo_url = None
+            for auth_option in settings.AUTHENTICATION_OPTIONS['external']:
+                if auth_option['idp_alias'] == idp_alias:
+                    idp_token_url = auth_option.get('idp_token_url')
+                    userinfo_url = auth_option.get('userinfo_url')
+                    break
+            if idp_token_url is None or userinfo_url is None:
+                logger.debug(f"idp_token_url and/or userinfo_url not set for {idp_alias} "
+                             "in AUTHENTICATION_OPTIONS, skipping retrieval of external IDP userinfo")
+                return
             access_token = token['access_token']
             logger.debug(f"access_token={access_token} for idp_alias={idp_alias}")
             # fetch the idp's token
@@ -284,10 +295,10 @@ class KeycloakBackend(object):
             # For the following to work, in Keycloak the IDP should have 'Store
             # Tokens' and 'Stored Tokens Readable' enabled and the user needs
             # the broker/read-token role
-            r = requests.get(f"https://iamdev.scigap.org/auth/realms/seagrid/broker/{idp_alias}/token", headers=headers)
+            r = requests.get(idp_token_url, headers=headers)
             idp_token = r.json()
             idp_headers = {'Authorization': f"Bearer {idp_token['access_token']}"}
-            r = requests.get("https://cilogon.org/oauth2/userinfo", headers=idp_headers)
+            r = requests.get(userinfo_url, headers=idp_headers)
             userinfo = r.json()
             logger.debug(f"userinfo={userinfo}")
 

[airavata-django-portal] 24/24: AIRAVATA-3319 Fix test

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit cd31d0f04ca097acf96e7f9daa7cc3c4581eac1f
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Dec 10 16:06:36 2021 -0500

    AIRAVATA-3319 Fix test
---
 django_airavata/apps/auth/tests/test_backends.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/django_airavata/apps/auth/tests/test_backends.py b/django_airavata/apps/auth/tests/test_backends.py
index bd696ac..dd4dd4f 100644
--- a/django_airavata/apps/auth/tests/test_backends.py
+++ b/django_airavata/apps/auth/tests/test_backends.py
@@ -30,6 +30,7 @@ GATEWAY_ID = "gateway-id"
     KEYCLOAK_VERIFY_SSL=KEYCLOAK_VERIFY_SSL,
     AUTHENTICATION_OPTIONS=AUTHENTICATION_OPTIONS,
     GATEWAY_ID=GATEWAY_ID,
+    PORTAL_ADMINS=[('Admin Name', 'admin@example.org')],
 )
 class KeycloakBackendTestCase(TestCase):
 

[airavata-django-portal] 21/24: AIRAVATA-3319 Show user profile fields with validity, and informational alerts

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 58ab1910a9ad335fd306bfcf9ab04f81ed55bb0d
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Dec 10 15:14:42 2021 -0500

    AIRAVATA-3319 Show user profile fields with validity, and informational alerts
---
 .../components/users/ExternalIDPUserInfoPanel.vue  |  6 +-
 .../src/components/users/UserDetailsContainer.vue  | 26 +++++++++
 .../src/components/users/UserProfilePanel.vue      | 65 ++++++++++++++++++++++
 django_airavata/apps/api/serializers.py            | 17 +++++-
 .../js/models/IAMUserProfile.js                    |  4 ++
 django_airavata/apps/auth/models.py                | 18 ++++--
 6 files changed, 129 insertions(+), 7 deletions(-)

diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ExternalIDPUserInfoPanel.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ExternalIDPUserInfoPanel.vue
index 22de442..52e863f 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ExternalIDPUserInfoPanel.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/ExternalIDPUserInfoPanel.vue
@@ -2,8 +2,10 @@
   <b-card header="External IDP Userinfo">
     <b-table :items="items" small borderless sort-by="claim" />
     <small class="text-muted"
-      >IDP alias is {{ externalIDPUserInfo.idp_alias || "N/A" }}</small
-    >
+      >This is the user information provided by the user's authentication
+      provider. The IDP alias used is
+      {{ externalIDPUserInfo.idp_alias || "N/A" }}.
+    </small>
   </b-card>
 </template>
 
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
index 9f56cf2..bc27702 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserDetailsContainer.vue
@@ -4,6 +4,19 @@
       title="User Profile"
       :active="iamUserProfile.airavataUserProfileExists"
     >
+      <b-alert
+        variant="warning"
+        show
+        v-if="!iamUserProfile.userProfileComplete"
+      >
+        This user has not completed their user profile. An incomplete user
+        profile is shown below.
+      </b-alert>
+      <b-alert variant="danger" show v-if="isUsernameInvalid">
+        The user has an invalid username. Please use
+        <strong>Change Username</strong> under the
+        <strong>Troubleshooting</strong> tab to fix the user's username.
+      </b-alert>
       <edit-groups-panel
         v-if="iamUserProfile.airavataUserProfileExists"
         :value="localIAMUserProfile.groups"
@@ -11,6 +24,7 @@
         :airavata-internal-user-id="iamUserProfile.airavataInternalUserId"
         @save="groupsUpdated"
       />
+      <user-profile-panel :iamUserProfile="iamUserProfile" />
       <external-idp-user-info-panel
         v-if="hasExternalIDPUserInfo"
         :externalIDPUserInfo="localIAMUserProfile.externalIDPUserInfo"
@@ -24,6 +38,7 @@
         v-if="
           iamUserProfile.enabled &&
           iamUserProfile.emailVerified &&
+          iamUserProfile.userProfileComplete &&
           !iamUserProfile.airavataUserProfileExists
         "
         :username="iamUserProfile.userId"
@@ -40,6 +55,10 @@
         :username="iamUserProfile.userId"
         @delete-user="$emit('delete-user', $event)"
       />
+      <b-alert variant="danger" show v-if="isUsernameInvalid">
+        The user has an invalid username. Please fix the user's username so that
+        they can complete their user profile.
+      </b-alert>
       <change-username-panel
         :username="iamUserProfile.userId"
         :email="iamUserProfile.email"
@@ -58,6 +77,7 @@ import DeleteUserPanel from "./DeleteUserPanel";
 import ChangeUsernamePanel from "./ChangeUsernamePanel.vue";
 import EditGroupsPanel from "./EditGroupsPanel.vue";
 import ExternalIDPUserInfoPanel from "./ExternalIDPUserInfoPanel.vue";
+import UserProfilePanel from "./UserProfilePanel.vue";
 
 export default {
   name: "user-details-container",
@@ -79,6 +99,7 @@ export default {
     ChangeUsernamePanel,
     EditGroupsPanel,
     "external-idp-user-info-panel": ExternalIDPUserInfoPanel,
+    UserProfilePanel,
   },
   data() {
     return {
@@ -102,6 +123,11 @@ export default {
         Object.keys(this.localIAMUserProfile.externalIDPUserInfo).length !== 0
       );
     },
+    isUsernameInvalid() {
+      return (
+        this.iamUserProfile.userProfileInvalidFields.indexOf("username") >= 0
+      );
+    },
   },
 };
 </script>
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserProfilePanel.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserProfilePanel.vue
new file mode 100644
index 0000000..3468956
--- /dev/null
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/UserProfilePanel.vue
@@ -0,0 +1,65 @@
+<template>
+  <b-card header="User Profile">
+    <b-table :items="items" :fields="fields" small borderless>
+      <template #cell(value)="{ value, item }">
+        <i v-if="item.valid" class="fas fa-check text-success"></i>
+        <i v-if="!item.valid" class="fas fa-times text-danger"></i>
+        {{ value }}
+      </template>
+    </b-table>
+  </b-card>
+</template>
+
+<script>
+import { models } from "django-airavata-api";
+export default {
+  props: {
+    iamUserProfile: {
+      type: models.IAMUserProfile,
+      required: true,
+    },
+  },
+  computed: {
+    fields() {
+      return ["name", "value"];
+    },
+    items() {
+      if (!this.iamUserProfile) {
+        return [];
+      } else {
+        return [
+          {
+            name: "Username",
+            value: this.iamUserProfile.userId,
+            valid: this.isValid("username"),
+          },
+          {
+            name: "Email",
+            value: this.iamUserProfile.email,
+            valid: this.isValid("email"),
+          },
+          {
+            name: "First Name",
+            value: this.iamUserProfile.firstName,
+            valid: this.isValid("first_name"),
+          },
+          {
+            name: "Last Name",
+            value: this.iamUserProfile.lastName,
+            valid: this.isValid("last_name"),
+          },
+        ];
+      }
+    },
+  },
+  methods: {
+    isValid(fieldName) {
+      return (
+        this.iamUserProfile.userProfileInvalidFields.indexOf(fieldName) < 0
+      );
+    },
+  },
+};
+</script>
+
+<style></style>
diff --git a/django_airavata/apps/api/serializers.py b/django_airavata/apps/api/serializers.py
index a939d0c..207b57e 100644
--- a/django_airavata/apps/api/serializers.py
+++ b/django_airavata/apps/api/serializers.py
@@ -955,6 +955,7 @@ class IAMUserProfile(serializers.Serializer):
     userHasWriteAccess = serializers.SerializerMethodField()
     newUsername = serializers.CharField(write_only=True, required=False)
     externalIDPUserInfo = serializers.SerializerMethodField()
+    userProfileInvalidFields = serializers.SerializerMethodField()
 
     def update(self, instance, validated_data):
         existing_group_ids = [group.id for group in instance['groups']]
@@ -970,7 +971,6 @@ class IAMUserProfile(serializers.Serializer):
         return request.is_gateway_admin
 
     def get_externalIDPUserInfo(self, userProfile):
-
         result = {}
         try:
             if get_user_model().objects.filter(username=userProfile['userId']).exists():
@@ -985,6 +985,21 @@ class IAMUserProfile(serializers.Serializer):
             log.warning(f"Failed to load idp_userinfo for {userProfile['userId']}", exc_info=e)
         return result
 
+    def get_userProfileInvalidFields(self, userProfile):
+        try:
+            User = get_user_model()
+            if User.objects.filter(username=userProfile['userId']).exists():
+                django_user = User.objects.get(username=userProfile['userId'])
+                if hasattr(django_user, 'user_profile'):
+                    return django_user.user_profile.invalid_fields
+                else:
+                    # For backwards compatibility, return True if no user_profile
+                    return []
+        except Exception as e:
+            log.warning(f"Failed to get user_profile.invalid_fields for {userProfile['userId']}", exc_info=e)
+        return []
+
+
 
 class AckNotificationSerializer(serializers.ModelSerializer):
     class Meta:
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/IAMUserProfile.js b/django_airavata/apps/api/static/django_airavata_api/js/models/IAMUserProfile.js
index 5f804e0..649636f 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/IAMUserProfile.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/IAMUserProfile.js
@@ -23,10 +23,14 @@ const FIELDS = [
   },
   "userHasWriteAccess",
   "externalIDPUserInfo",
+  "userProfileInvalidFields",
 ];
 
 export default class IAMUserProfile extends BaseModel {
   constructor(data = {}) {
     super(FIELDS, data);
   }
+  get userProfileComplete() {
+    return this.userProfileInvalidFields.length === 0;
+  }
 }
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index 31b5b35..65ee94e 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -65,10 +65,7 @@ class UserProfile(models.Model):
 
     @property
     def is_complete(self):
-        return (self.is_username_valid and
-                self.is_first_name_valid and
-                self.is_last_name_valid and
-                self.is_email_valid)
+        return len(self.invalid_fields) == 0
 
     @property
     def is_username_valid(self):
@@ -100,6 +97,19 @@ class UserProfile(models.Model):
         # before it is set or updated
         return self.is_non_empty(self.user.email)
 
+    @property
+    def invalid_fields(self):
+        result = []
+        if not self.is_username_valid:
+            result.append('username')
+        if not self.is_email_valid:
+            result.append('email')
+        if not self.is_first_name_valid:
+            result.append('first_name')
+        if not self.is_last_name_valid:
+            result.append('last_name')
+        return result
+
     def is_non_empty(self, value: str):
         return value is not None and value.strip() != ""
 

[airavata-django-portal] 09/24: AIRAVATA-3319 Add admin email alerting when user ends up with invalid username

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 6b06651b9ae204f3279b1eabf3f2ba8a36ddef7d
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Tue Jul 27 15:19:03 2021 -0400

    AIRAVATA-3319 Add admin email alerting when user ends up with invalid username
---
 django_airavata/apps/auth/backends.py | 10 ++++++++
 django_airavata/apps/auth/utils.py    | 45 +++++++++++++++++++++++++++++++++++
 2 files changed, 55 insertions(+)

diff --git a/django_airavata/apps/auth/backends.py b/django_airavata/apps/auth/backends.py
index 2197928..a60fd59 100644
--- a/django_airavata/apps/auth/backends.py
+++ b/django_airavata/apps/auth/backends.py
@@ -239,6 +239,16 @@ class KeycloakBackend(object):
         user.first_name = first_name
         user.last_name = last_name
         user.save()
+
+        # Since only Admins can fix a bad username, alert Admins if the user has
+        # an invalid username
+        if not user_profile.is_username_valid:
+            try:
+                utils.send_admin_alert_about_invalid_username(
+                    request, username, email, first_name, last_name)
+            except Exception:
+                logger.exception(f"Failed to send alert about username being invalid: {username}")
+
         return user
 
     def _get_or_create_user(self, sub, username):
diff --git a/django_airavata/apps/auth/utils.py b/django_airavata/apps/auth/utils.py
index 1c29320..6da77ea 100644
--- a/django_airavata/apps/auth/utils.py
+++ b/django_airavata/apps/auth/utils.py
@@ -139,6 +139,51 @@ def send_new_user_email(request, username, email, first_name, last_name):
     msg.send()
 
 
+def send_admin_alert_about_invalid_username(request, username, email, first_name, last_name):
+    domain, port = split_domain_port(request.get_host())
+    context = Context({
+        "username": username,
+        "email": email,
+        "first_name": first_name,
+        "last_name": last_name,
+        "portal_title": settings.PORTAL_TITLE,
+        "gateway_id": settings.GATEWAY_ID,
+        "http_host": domain,
+    })
+    subject = Template("Please fix invalid username: a user of {{portal_title}} ({{http_host}}) has an invalid username ({{username}})").render(context)
+    body = Template("""
+    <p>
+    Dear Admin,
+    </p>
+
+    <p>
+    The following user has an invalid username:
+    </p>
+
+    <p>Username: {{username}}</p>
+    <p>Name: {{first_name}} {{last_name}}</p>
+    <p>Email: {{email}}</p>
+
+    <p>
+    This likely happened because there was no appropriate user attribute to use
+    for the user's username when the user logged in through an external identity
+    provider.  Please update the username to the user's email address or some
+    other valid value in the <a href="https://{{http_host}}/admin/users/">Manage
+    Users</a> view in the portal.
+    </p>
+    """.strip()).render(context)
+    msg = EmailMessage(subject=subject,
+                       body=body,
+                       from_email="{} <{}>".format(
+                           settings.PORTAL_TITLE,
+                           settings.SERVER_EMAIL),
+                       to=[a[1] for a in getattr(settings,
+                                                 'PORTAL_ADMINS',
+                                                 settings.ADMINS)])
+    msg.content_subtype = 'html'
+    msg.send()
+
+
 def send_email_to_user(template_id, context):
     email_template = models.EmailTemplate.objects.get(pk=template_id)
     subject = Template(email_template.subject).render(context)

[airavata-django-portal] 12/24: AIRAVATA-3468 Create user profile if it doesn't exist

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit ef6c1a01aa5a14607ce4ec7dfa69be08ab0cf31b
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Mon Aug 2 10:01:54 2021 -0400

    AIRAVATA-3468 Create user profile if it doesn't exist
---
 django_airavata/apps/auth/serializers.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/django_airavata/apps/auth/serializers.py b/django_airavata/apps/auth/serializers.py
index 40723fd..29e2ef9 100644
--- a/django_airavata/apps/auth/serializers.py
+++ b/django_airavata/apps/auth/serializers.py
@@ -56,6 +56,12 @@ class UserSerializer(serializers.ModelSerializer):
         instance.save()
         # save in the user profile service too
         user_profile_client = request.profile_service['user_profile']
+        # Check if user profile exists and create it if not first. User Profile
+        # doesn't get created until the profile is complete, so it may not exist yet.
+        if not user_profile_client.doesUserExist(request.authz_token,
+                                                 request.user.username,
+                                                 settings.GATEWAY_ID):
+            user_profile_client.initializeUserProfile(request.authz_token)
         airavata_user_profile = user_profile_client.getUserProfileById(
             request.authz_token, request.user.username, settings.GATEWAY_ID)
         airavata_user_profile.firstName = instance.first_name

[airavata-django-portal] 08/24: AIRAVATA-3468 Inform user that they must complete profile

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 81a4c177a1018f6eb85367ff998889c9927166e4
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Mon Jul 26 14:58:38 2021 -0400

    AIRAVATA-3468 Inform user that they must complete profile
---
 .../apps/api/static/django_airavata_api/js/models/User.js      | 10 +++++++++-
 django_airavata/apps/auth/serializers.py                       |  6 +++++-
 .../js/containers/UserProfileContainer.vue                     |  3 +++
 3 files changed, 17 insertions(+), 2 deletions(-)

diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/User.js b/django_airavata/apps/api/static/django_airavata_api/js/models/User.js
index 53cbf79..648721a 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/User.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/User.js
@@ -1,6 +1,14 @@
 import BaseModel from "./BaseModel";
 
-const FIELDS = ["id", "username", "first_name", "last_name", "email", "pending_email_change"];
+const FIELDS = [
+  "id",
+  "username",
+  "first_name",
+  "last_name",
+  "email",
+  "pending_email_change",
+  "complete",
+];
 
 export default class User extends BaseModel {
   constructor(data = {}) {
diff --git a/django_airavata/apps/auth/serializers.py b/django_airavata/apps/auth/serializers.py
index f12854e..40723fd 100644
--- a/django_airavata/apps/auth/serializers.py
+++ b/django_airavata/apps/auth/serializers.py
@@ -23,10 +23,11 @@ class PendingEmailChangeSerializer(serializers.ModelSerializer):
 class UserSerializer(serializers.ModelSerializer):
 
     pending_email_change = serializers.SerializerMethodField()
+    complete = serializers.SerializerMethodField()
 
     class Meta:
         model = get_user_model()
-        fields = ['id', 'username', 'first_name', 'last_name', 'email', 'pending_email_change']
+        fields = ['id', 'username', 'first_name', 'last_name', 'email', 'pending_email_change', 'complete']
 
     def get_pending_email_change(self, instance):
         request = self.context['request']
@@ -37,6 +38,9 @@ class UserSerializer(serializers.ModelSerializer):
         else:
             return None
 
+    def get_complete(self, instance):
+        return instance.user_profile.is_complete
+
     @atomic
     def update(self, instance, validated_data):
         request = self.context['request']
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
index d6eebce..da4e001 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
@@ -1,6 +1,9 @@
 <template>
   <div>
     <h1 class="h4 mb-4">User Profile Editor</h1>
+    <b-alert :show="user && !user.complete"
+      >Please complete your user profile before continuing.</b-alert
+    >
     <user-profile-editor
       v-if="user"
       v-model="user"

[airavata-django-portal] 10/24: AIRAVATA-3468 Add link for navigating back to the dashboard

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit c6efb480ef0237804b8b42f62a9d961ad26764a1
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Wed Jul 28 14:28:28 2021 -0400

    AIRAVATA-3468 Add link for navigating back to the dashboard
---
 .../static/django_airavata_auth/js/components/UserProfileEditor.vue    | 2 +-
 .../static/django_airavata_auth/js/containers/UserProfileContainer.vue | 3 +++
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
index 23b9104..a59050b 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
@@ -76,7 +76,7 @@ export default {
   },
   validations() {
     const usernameRegex = helpers.regex("username", /^[a-z0-9_-]+$/);
-    const emailOrMatchesRegex = or(usernameRegex, sameAs('email'));
+    const emailOrMatchesRegex = or(usernameRegex, sameAs("email"));
     return {
       user: {
         username: {
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
index da4e001..3a37faa 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
@@ -10,6 +10,9 @@
       @save="onSave"
       @resend-email-verification="resendEmailVerification"
     />
+    <b-link class="text-muted small" href="/workspace/dashboard"
+      >Return to Dashboard</b-link
+    >
   </div>
 </template>
 

[airavata-django-portal] 18/24: AIRAVATA-3319 newUsername only needed when changing username

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 133ff73519ad73fff191f72852e0cbe73b69b9b2
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Thu Dec 9 12:32:32 2021 -0500

    AIRAVATA-3319 newUsername only needed when changing username
---
 django_airavata/apps/api/serializers.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/django_airavata/apps/api/serializers.py b/django_airavata/apps/api/serializers.py
index fcceaa3..11f6646 100644
--- a/django_airavata/apps/api/serializers.py
+++ b/django_airavata/apps/api/serializers.py
@@ -952,7 +952,7 @@ class IAMUserProfile(serializers.Serializer):
         lookup_field='userId',
         lookup_url_kwarg='user_id')
     userHasWriteAccess = serializers.SerializerMethodField()
-    newUsername = serializers.CharField(write_only=True)
+    newUsername = serializers.CharField(write_only=True, required=False)
 
     def update(self, instance, validated_data):
         existing_group_ids = [group.id for group in instance['groups']]

[airavata-django-portal] 01/24: AIRAVATA-3468 Check if profile is complete and redirect to profile editor if not

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 38f28cc6cd19eee98e29ba646ef66dafb85a325c
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Thu Jun 24 11:49:28 2021 -0400

    AIRAVATA-3468 Check if profile is complete and redirect to profile editor if not
---
 django_airavata/apps/auth/backends.py              |  1 +
 django_airavata/apps/auth/middleware.py            | 26 ++++++++++++--
 django_airavata/apps/auth/models.py                | 40 +++++++++++++++++++---
 django_airavata/apps/auth/signals.py               | 17 +++++----
 .../js/components/UserProfileEditor.vue            | 18 ++++++++--
 django_airavata/settings.py                        |  1 +
 6 files changed, 87 insertions(+), 16 deletions(-)

diff --git a/django_airavata/apps/auth/backends.py b/django_airavata/apps/auth/backends.py
index d7e9176..96c57fd 100644
--- a/django_airavata/apps/auth/backends.py
+++ b/django_airavata/apps/auth/backends.py
@@ -230,6 +230,7 @@ class KeycloakBackend(object):
 
         # Update User model fields
         user = user_profile.user
+        user.username = username
         user.email = email
         user.first_name = first_name
         user.last_name = last_name
diff --git a/django_airavata/apps/auth/middleware.py b/django_airavata/apps/auth/middleware.py
index eb8a722..7921405 100644
--- a/django_airavata/apps/auth/middleware.py
+++ b/django_airavata/apps/auth/middleware.py
@@ -4,8 +4,10 @@ import logging
 
 from django.conf import settings
 from django.contrib.auth import logout
+from django.shortcuts import redirect
 
 from . import utils
+from django.urls import reverse
 
 log = logging.getLogger(__name__)
 
@@ -33,7 +35,10 @@ def gateway_groups_middleware(get_response):
     """Add 'is_gateway_admin' and 'is_read_only_gateway_admin' to request."""
     def middleware(request):
 
-        if not request.user.is_authenticated or not request.authz_token:
+        request.is_gateway_admin = False
+        request.is_read_only_gateway_admin = False
+
+        if not request.user.is_authenticated or not request.authz_token or not request.user.user_profile.is_complete:
             return get_response(request)
 
         try:
@@ -66,8 +71,23 @@ def gateway_groups_middleware(get_response):
         except Exception as e:
             log.warning("Failed to set is_gateway_admin, "
                         "is_read_only_gateway_admin for user", exc_info=e)
-            request.is_gateway_admin = False
-            request.is_read_only_gateway_admin = False
 
         return get_response(request)
     return middleware
+
+
+def user_profile_completeness_check(get_response):
+    """Check if user profile is complete and if not, redirect to user profile editor."""
+    def middleware(request):
+
+        if not request.user.is_authenticated:
+            return get_response(request)
+
+        if (not request.user.user_profile.is_complete and
+                request.path != reverse('django_airavata_auth:user_profile') and
+                request.path != reverse('django_airavata_auth:logout') and
+                request.META['HTTP_ACCEPT'] != 'application/json'):
+            return redirect('django_airavata_auth:user_profile')
+        else:
+            return get_response(request)
+    return middleware
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index b2c7b17..2383992 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -3,6 +3,9 @@ import uuid
 from django.conf import settings
 from django.db import models
 
+from . import forms
+from django.core.exceptions import ValidationError
+
 VERIFY_EMAIL_TEMPLATE = 1
 NEW_USER_EMAIL_TEMPLATE = 2
 PASSWORD_RESET_EMAIL_TEMPLATE = 3
@@ -58,11 +61,38 @@ class UserProfile(models.Model):
 
     @property
     def is_complete(self):
-        # TODO: implement this to check if there are any missing fields on the
-        # User model (email, first_name, last_name) or if the username was is
-        # invalid (for example if defaulted to the IdP's 'sub' claim) or if
-        # there are any extra profile fields that are not valid
-        return False
+        return (self.is_username_valid and
+                self.is_first_name_valid and
+                self.is_last_name_valid and
+                self.is_email_valid)
+
+    @property
+    def is_username_valid(self):
+        # use forms.USERNAME_VALIDATOR with an exception when the username is
+        # equal to the email
+        try:
+            forms.USERNAME_VALIDATOR(self.user.username)
+            validates = True
+        except ValidationError:
+            validates = False
+        return (validates or (self.is_email_valid and self.user.email == self.user.username))
+
+    @property
+    def is_first_name_valid(self):
+        return self.is_non_empty(self.user.first_name)
+
+    @property
+    def is_last_name_valid(self):
+        return self.is_non_empty(self.user.last_name)
+
+    @property
+    def is_email_valid(self):
+        # Only checking for non-empty only; assumption is that email is verified
+        # before it is set or updated
+        return self.is_non_empty(self.user.email)
+
+    def is_non_empty(self, value: str):
+        return value is not None and value.strip() != ""
 
 
 class UserInfo(models.Model):
diff --git a/django_airavata/apps/auth/signals.py b/django_airavata/apps/auth/signals.py
index eb12e17..466e20b 100644
--- a/django_airavata/apps/auth/signals.py
+++ b/django_airavata/apps/auth/signals.py
@@ -42,11 +42,16 @@ def initialize_user_profile(sender, request, user, **kwargs):
         if not user_profile_client_pool.doesUserExist(request.authz_token,
                                                       user.username,
                                                       settings.GATEWAY_ID):
-            user_profile_client_pool.initializeUserProfile(request.authz_token)
-            log.info("initialized user profile for {}".format(user.username))
-            # Since user profile created, inform admins of new user
-            utils.send_new_user_email(
-                request, user.username, user.email, user.first_name, user.last_name)
-            log.info("sent new user email for user {}".format(user.username))
+            if user.user_profile.is_complete:
+                user_profile_client_pool.initializeUserProfile(request.authz_token)
+                log.info("initialized user profile for {}".format(user.username))
+                # Since user profile created, inform admins of new user
+                utils.send_new_user_email(
+                    request, user.username, user.email, user.first_name, user.last_name)
+                log.info("sent new user email for user {}".format(user.username))
+            else:
+                log.info(f"user profile not complete for {user.username}, "
+                         "skipping initializing Airavata user profile")
+
     else:
         log.warning(f"Logged in user {user.username} has no access token")
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
index 506114c..b6b31fe 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
@@ -1,7 +1,15 @@
 <template>
   <b-card>
     <b-form-group label="Username">
-      <b-form-input disabled :value="user.username" />
+      <b-form-input
+        v-model="$v.user.username.$model"
+        @keydown.native.enter="save"
+        :state="validateState($v.user.username)"
+      />
+      <b-form-invalid-feedback v-if="!$v.user.username.emailOrMatchesRegex">
+        Username can only contain lowercase letters, numbers, underscores and
+        hyphens OR it can be the same as the email address.
+      </b-form-invalid-feedback>
     </b-form-group>
     <b-form-group label="First Name">
       <b-form-input
@@ -46,7 +54,7 @@
 import { models } from "django-airavata-api";
 import { errors } from "django-airavata-common-ui";
 import { validationMixin } from "vuelidate";
-import { email, required } from "vuelidate/lib/validators";
+import { email, helpers, or, required, sameAs } from "vuelidate/lib/validators";
 
 export default {
   name: "user-profile-editor",
@@ -63,8 +71,14 @@ export default {
     };
   },
   validations() {
+    const usernameRegex = helpers.regex("username", /^[a-z0-9_-]+$/);
+    const emailOrMatchesRegex = or(usernameRegex, sameAs('email'));
     return {
       user: {
+        username: {
+          required,
+          emailOrMatchesRegex,
+        },
         first_name: {
           required,
         },
diff --git a/django_airavata/settings.py b/django_airavata/settings.py
index 930c36a..d17592c 100644
--- a/django_airavata/settings.py
+++ b/django_airavata/settings.py
@@ -101,6 +101,7 @@ MIDDLEWARE = [
     'django_airavata.apps.auth.middleware.gateway_groups_middleware',
     # Wagtail related middleware
     'wagtail.contrib.redirects.middleware.RedirectMiddleware',
+    'django_airavata.apps.auth.middleware.user_profile_completeness_check',
 ]
 
 ROOT_URLCONF = 'django_airavata.urls'

[airavata-django-portal] 15/24: AIRAVATA-3319 Clarify username_initialized and is_username_valid rules

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit b6f6b76b5a93fb1a36c9cf083a52ce99645bca5f
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Tue Dec 7 10:04:30 2021 -0500

    AIRAVATA-3319 Clarify username_initialized and is_username_valid rules
---
 django_airavata/apps/auth/backends.py              |  9 ----
 django_airavata/apps/auth/models.py                | 14 +++++-
 .../js/containers/UserProfileContainer.vue         |  4 +-
 django_airavata/apps/auth/utils.py                 | 51 ++--------------------
 4 files changed, 17 insertions(+), 61 deletions(-)

diff --git a/django_airavata/apps/auth/backends.py b/django_airavata/apps/auth/backends.py
index 2c41bf5..b6e6edf 100644
--- a/django_airavata/apps/auth/backends.py
+++ b/django_airavata/apps/auth/backends.py
@@ -240,15 +240,6 @@ class KeycloakBackend(object):
         user.last_name = last_name
         user.save()
 
-        # Since only Admins can fix a bad username, alert Admins if the user has
-        # an invalid username
-        if not user_profile.is_username_valid:
-            try:
-                utils.send_admin_alert_about_invalid_username(
-                    request, username, email, first_name, last_name)
-            except Exception:
-                logger.exception(f"Failed to send alert about username being invalid: {username}")
-
         return user
 
     def _get_or_create_user(self, sub, username):
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index 8296311..cfa53a3 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -58,6 +58,12 @@ class UserProfile(models.Model):
     # TODO: maybe this can be derived from whether there exists an Airavata
     # User Profile for the user's username
     username_locked = models.BooleanField(default=False)
+    # This flag is only used for external IDP users. It indicates that the
+    # username was properly initialized when the user logged in through the
+    # external IDP. As for now that means that the username was set to the
+    # user's email address. Sometimes the automatic assignment of username fails
+    # and an administrator needs to intervene. When an administrator sets the
+    # user's username this flag will also be set to true.
     username_initialized = models.BooleanField(default=False)
 
     @property
@@ -69,6 +75,11 @@ class UserProfile(models.Model):
 
     @property
     def is_username_valid(self):
+
+        # Username was provided either by external IDP or manually set by an admin
+        if self.username_initialized:
+            return True
+
         # use forms.USERNAME_VALIDATOR with an exception when the username is
         # equal to the email
         try:
@@ -76,8 +87,7 @@ class UserProfile(models.Model):
             validates = True
         except ValidationError:
             validates = False
-        # TODO: should be valid if matching an old email address too
-        return (validates or (self.is_email_valid and self.user.email == self.user.username))
+        return validates
 
     @property
     def is_first_name_valid(self):
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
index 03f7b3f..f2ca9cc 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
@@ -5,8 +5,8 @@
       Unfortunately the username on your profile is invalid, which prevents
       creating or updating your user profile. The administrators have been
       notified and will be able to update your user account with a valid
-      username. Someone will notify you once your username has been updated to a
-      valid value.
+      username. An administrator will notify you once your username has been
+      updated to a valid value.
     </b-alert>
     <b-alert v-else-if="user && !user.complete" show>
       >Please complete your user profile before continuing.</b-alert
diff --git a/django_airavata/apps/auth/utils.py b/django_airavata/apps/auth/utils.py
index d7e714c..7c29731 100644
--- a/django_airavata/apps/auth/utils.py
+++ b/django_airavata/apps/auth/utils.py
@@ -139,51 +139,6 @@ def send_new_user_email(request, username, email, first_name, last_name):
     msg.send()
 
 
-def send_admin_alert_about_invalid_username(request, username, email, first_name, last_name):
-    domain, port = split_domain_port(request.get_host())
-    context = Context({
-        "username": username,
-        "email": email,
-        "first_name": first_name,
-        "last_name": last_name,
-        "portal_title": settings.PORTAL_TITLE,
-        "gateway_id": settings.GATEWAY_ID,
-        "http_host": domain,
-    })
-    subject = Template("Please fix invalid username: a user of {{portal_title}} ({{http_host}}) has an invalid username ({{username}})").render(context)
-    body = Template("""
-    <p>
-    Dear Admin,
-    </p>
-
-    <p>
-    The following user has an invalid username:
-    </p>
-
-    <p>Username: {{username}}</p>
-    <p>Name: {{first_name}} {{last_name}}</p>
-    <p>Email: {{email}}</p>
-
-    <p>
-    This likely happened because there was no appropriate user attribute to use
-    for the user's username when the user logged in through an external identity
-    provider.  Please update the username to the user's email address or some
-    other valid value in the <a href="https://{{http_host}}/admin/users/">Manage
-    Users</a> view in the portal.
-    </p>
-    """.strip()).render(context)
-    msg = EmailMessage(subject=subject,
-                       body=body,
-                       from_email="{} <{}>".format(
-                           settings.PORTAL_TITLE,
-                           settings.SERVER_EMAIL),
-                       to=[a[1] for a in getattr(settings,
-                                                 'PORTAL_ADMINS',
-                                                 settings.ADMINS)])
-    msg.content_subtype = 'html'
-    msg.send()
-
-
 def send_admin_alert_about_uninitialized_username(request, username, email, first_name, last_name):
     domain, port = split_domain_port(request.get_host())
     context = Context({
@@ -195,15 +150,15 @@ def send_admin_alert_about_uninitialized_username(request, username, email, firs
         "gateway_id": settings.GATEWAY_ID,
         "http_host": domain,
     })
-    subject = Template("Please fix username: a user of {{portal_title}} ({{http_host}}) has been assigned a random username ({{username}})").render(context)
+    subject = Template("Please fix username: a user of {{portal_title}} ({{http_host}}) has been assigned an auto-generated username ({{username}})").render(context)
     body = Template("""
     <p>
     Dear Admin,
     </p>
 
     <p>
-    The following user has a random username because the system could not
-    determine a proper username:
+    The following user has an auto-generated username because the system could
+    not determine a proper username:
     </p>
 
     <p>Username: {{username}}</p>

[airavata-django-portal] 04/24: AIRAVATA-3319 Admin API for updating a user's username

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 3013690e0077fad61d7fd77167b44b07c9adf063
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Thu Jul 1 10:11:54 2021 -0400

    AIRAVATA-3319 Admin API for updating a user's username
---
 django_airavata/apps/api/views.py             | 12 ++++++++++
 django_airavata/apps/auth/iam_admin_client.py | 34 +++++++++++++++++++++++++++
 2 files changed, 46 insertions(+)

diff --git a/django_airavata/apps/api/views.py b/django_airavata/apps/api/views.py
index 7e6251b..ded1201 100644
--- a/django_airavata/apps/api/views.py
+++ b/django_airavata/apps/api/views.py
@@ -1597,6 +1597,18 @@ class IAMUserViewSet(mixins.RetrieveModelMixin,
                                            context={'request': request})
         return Response(serializer.data)
 
+    @action(methods=['put'], detail=True)
+    def update_username(self, request, user_id=None):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        old_username = user_id
+        new_username = serializer.validated_data['userId']
+        iam_admin_client.update_username(old_username, new_username)
+        instance = self.get_instance(new_username)
+        serializer = self.serializer_class(instance=instance,
+                                           context={'request': request})
+        return Response(serializer.data)
+
     def _convert_user_profile(self, user_profile):
         user_profile_client = self.request.profile_service['user_profile']
         group_manager_client = self.request.profile_service['group_manager']
diff --git a/django_airavata/apps/auth/iam_admin_client.py b/django_airavata/apps/auth/iam_admin_client.py
index 113d2a8..3d2b29f 100644
--- a/django_airavata/apps/auth/iam_admin_client.py
+++ b/django_airavata/apps/auth/iam_admin_client.py
@@ -3,6 +3,10 @@ Wrapper around the IAM Admin Services client.
 """
 
 import logging
+from urllib.parse import urlparse
+
+import requests
+from django.conf import settings
 
 from django_airavata.utils import iamadmin_client_pool
 
@@ -61,3 +65,33 @@ def reset_user_password(username, new_password):
     authz_token = utils.get_service_account_authz_token()
     return iamadmin_client_pool.resetUserPassword(
         authz_token, username, new_password)
+
+
+def update_username(username, new_username):
+    # make sure that new_username is available
+    if not is_username_available(new_username):
+        raise Exception(f"Can't change username of {username} to {new_username} because it is not available")
+    # fetch user representation
+    authz_token = utils.get_service_account_authz_token()
+    headers = {'Authorization': f'Bearer {authz_token.accessToken}'}
+    parsed = urlparse(settings.KEYCLOAK_AUTHORIZE_URL)
+    r = requests.get(f"{parsed.scheme}://{parsed.netloc}/auth/admin/realms/{settings.GATEWAY_ID}/users",
+                     params={'username': username},
+                     headers=headers)
+    r.raise_for_status()
+    user_list = r.json()
+    user = None
+    # The users search finds partial matches. Loop to find the exact match.
+    for u in user_list:
+        if u['username'] == username:
+            user = u
+            break
+    if user is None:
+        raise Exception(f"Could not find user {username}")
+
+    # update username
+    user['username'] = new_username
+    r = requests.put(f"{parsed.scheme}://{parsed.netloc}/auth/admin/realms/{settings.GATEWAY_ID}/users/{user['id']}",
+                     json=user,
+                     headers=headers)
+    r.raise_for_status()

[airavata-django-portal] 17/24: AIRAVATA-3319 Remove username_locked since it's not needed

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 1026fc735218333b1d52f27e0019cdb55ba7059e
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Tue Dec 7 11:41:36 2021 -0500

    AIRAVATA-3319 Remove username_locked since it's not needed
---
 .../0011_remove_userprofile_username_locked.py          | 17 +++++++++++++++++
 django_airavata/apps/auth/models.py                     |  3 ---
 2 files changed, 17 insertions(+), 3 deletions(-)

diff --git a/django_airavata/apps/auth/migrations/0011_remove_userprofile_username_locked.py b/django_airavata/apps/auth/migrations/0011_remove_userprofile_username_locked.py
new file mode 100644
index 0000000..ca95605
--- /dev/null
+++ b/django_airavata/apps/auth/migrations/0011_remove_userprofile_username_locked.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.8 on 2021-12-07 16:40
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('django_airavata_auth', '0010_userprofile_username_initialized'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='userprofile',
+            name='username_locked',
+        ),
+    ]
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index cfa53a3..31b5b35 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -55,9 +55,6 @@ class UserProfile(models.Model):
     user = models.OneToOneField(
         settings.AUTH_USER_MODEL,
         on_delete=models.CASCADE, related_name="user_profile")
-    # TODO: maybe this can be derived from whether there exists an Airavata
-    # User Profile for the user's username
-    username_locked = models.BooleanField(default=False)
     # This flag is only used for external IDP users. It indicates that the
     # username was properly initialized when the user logged in through the
     # external IDP. As for now that means that the username was set to the