Merge branch 'master' into develop
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/field-editors/ExtendedUserProfileFieldEditor.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/field-editors/ExtendedUserProfileFieldEditor.vue
index 7f1371b..965fdd0 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/field-editors/ExtendedUserProfileFieldEditor.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/field-editors/ExtendedUserProfileFieldEditor.vue
@@ -6,6 +6,19 @@
         >This field is required.</b-form-invalid-feedback
       >
     </b-form-group>
+    <b-form-group
+      label="Checkbox Label"
+      label-cols="3"
+      v-if="extendedUserProfileField.field_type === 'user_agreement'"
+    >
+      <b-form-input
+        v-model="checkbox_label"
+        :state="validateState($v.checkbox_label)"
+      />
+      <b-form-invalid-feedback :state="validateState($v.checkbox_label)"
+        >This field is required.</b-form-invalid-feedback
+      >
+    </b-form-group>
     <b-form-group label="Help text" label-cols="3">
       <b-form-input v-model="help_text" />
     </b-form-group>
@@ -34,7 +47,7 @@
               <b-input-group-append>
                 <b-button
                   @click="handleChoiceMoveUp(choice)"
-                  :disabled="index === 0"
+                  :disabled="index === String(0)"
                   v-b-tooltip.hover.left
                   title="Move Up"
                 >
@@ -43,7 +56,8 @@
                 <b-button
                   @click="handleChoiceMoveDown(choice)"
                   :disabled="
-                    index === extendedUserProfileField.choices.length - 1
+                    index ===
+                    String(extendedUserProfileField.choices.length - 1)
                   "
                   v-b-tooltip.hover.left
                   title="Move Down"
@@ -189,7 +203,7 @@
 <script>
 import { mapGetters, mapMutations } from "vuex";
 import { validationMixin } from "vuelidate";
-import { required } from "vuelidate/lib/validators";
+import { required, requiredIf } from "vuelidate/lib/validators";
 import { errors } from "django-airavata-common-ui";
 export default {
   mixins: [validationMixin],
@@ -205,6 +219,15 @@
         this.$v.name.$touch();
       },
     },
+    checkbox_label: {
+      get() {
+        return this.extendedUserProfileField.checkbox_label;
+      },
+      set(value) {
+        this.setCheckboxLabel({ value, field: this.extendedUserProfileField });
+        this.$v.checkbox_label.$touch();
+      },
+    },
     help_text: {
       get() {
         return this.extendedUserProfileField.help_text;
@@ -249,12 +272,18 @@
     valid() {
       return !this.$v.$invalid;
     },
+    checkboxLabelIsRequired() {
+      return this.extendedUserProfileField.field_type === "user_agreement";
+    },
   },
   validations() {
     return {
       name: {
         required,
       },
+      checkbox_label: {
+        required: requiredIf("checkboxLabelIsRequired"),
+      },
       choices: {
         $each: {
           display_text: {
@@ -277,6 +306,7 @@
   methods: {
     ...mapMutations("extendedUserProfile", [
       "setName",
+      "setCheckboxLabel",
       "setHelpText",
       "setRequired",
       "setOther",
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/store/modules/extendedUserProfile.js b/django_airavata/apps/admin/static/django_airavata_admin/src/store/modules/extendedUserProfile.js
index a058bee..1c0d456 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/store/modules/extendedUserProfile.js
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/store/modules/extendedUserProfile.js
@@ -94,6 +94,9 @@
   setName(state, { value, field }) {
     setFieldProp(state, field, "name", value);
   },
+  setCheckboxLabel(state, { value, field }) {
+    setFieldProp(state, field, "checkbox_label", value);
+  },
   setHelpText(state, { value, field }) {
     setFieldProp(state, field, "help_text", value);
   },
diff --git a/django_airavata/apps/api/exceptions.py b/django_airavata/apps/api/exceptions.py
index 7caf17c..0676c12 100644
--- a/django_airavata/apps/api/exceptions.py
+++ b/django_airavata/apps/api/exceptions.py
@@ -4,6 +4,7 @@
 from django.core.exceptions import ObjectDoesNotExist
 from django.http import JsonResponse
 from rest_framework import status
+from rest_framework.exceptions import NotAuthenticated
 from rest_framework.response import Response
 from rest_framework.views import exception_handler
 from thrift.Thrift import TException
@@ -43,6 +44,11 @@
             {'detail': str(exc)},
             status=status.HTTP_404_NOT_FOUND)
 
+    if isinstance(exc, NotAuthenticated):
+        log.debug("NotAuthenticated", exc_info=exc)
+        if response is not None:
+            response.data['is_authenticated'] = False
+
     # Generic handler
     if response is None:
         log.error("API exception", exc_info=exc)
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/errors/ErrorUtils.js b/django_airavata/apps/api/static/django_airavata_api/js/errors/ErrorUtils.js
index 7832993..d69f70f 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/errors/ErrorUtils.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/errors/ErrorUtils.js
@@ -22,4 +22,38 @@
   isNotFoundError(error) {
     return this.isAPIException(error) && error.details.status === 404;
   },
+  /**
+   * Return true if the error is an unauthenticated error, i.e., the user needs
+   * to log in again.
+   *
+   * @param {Error} error
+   * @returns
+   * @see {@link buildLoginUrl} for utility to build re-login url
+   */
+  isUnauthenticatedError(error) {
+    return (
+      this.isAPIException(error) &&
+      [401, 403].includes(error.details.status) &&
+      "is_authenticated" in error.details.response &&
+      error.details.response.is_authenticated === false
+    );
+  },
+  /**
+   * Build a url that takes the user to the login page.
+   *
+   * @param {boolean} includeNextParameter - Add a 'next' url to the login url
+   *   that will take the user back to this page after login
+   * @returns
+   */
+  buildLoginUrl(includeNextParameter = true) {
+    let loginUrl = "/auth/login";
+    if (includeNextParameter) {
+      let currentURL = window.location.pathname;
+      if (window.location.search) {
+        currentURL += window.location.search;
+      }
+      loginUrl += `?next=${encodeURIComponent(currentURL)}`;
+    }
+    return loginUrl;
+  },
 };
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledError.js b/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledError.js
index fbde9a2..41ece6a 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledError.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledError.js
@@ -1,3 +1,5 @@
+import ErrorUtils from "./ErrorUtils";
+
 let idSequence = 0;
 class UnhandledError {
   constructor({
@@ -15,6 +17,10 @@
     this.suppressLogging = suppressLogging;
     this.createdDate = new Date();
   }
+
+  get isUnauthenticatedError() {
+    return ErrorUtils.isUnauthenticatedError(this.error);
+  }
 }
 
 export default UnhandledError;
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledErrorDispatcher.js b/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledErrorDispatcher.js
index 35074fd..10d75b1 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledErrorDispatcher.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledErrorDispatcher.js
@@ -21,10 +21,22 @@
   }
 
   reportUnhandledError(unhandledError) {
+    // Ignore unauthenticated errors that have already been displayed
+    if (
+      unhandledError.isUnauthenticatedError &&
+      UnhandledErrorList.list.some((e) => e.isUnauthenticatedError)
+    ) {
+      return;
+    }
+
     if (!unhandledError.suppressDisplay) {
       UnhandledErrorList.add(unhandledError);
     }
-    if (!unhandledError.suppressLogging) {
+    if (
+      !unhandledError.suppressLogging &&
+      // Don't log unauthenticated errors
+      !unhandledError.isUnauthenticatedError
+    ) {
       ErrorReporter.reportUnhandledError(unhandledError);
     }
   }
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/utils/FetchUtils.js b/django_airavata/apps/api/static/django_airavata_api/js/utils/FetchUtils.js
index 8a52802..f11e7d1 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/utils/FetchUtils.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/utils/FetchUtils.js
@@ -1,3 +1,4 @@
+import ErrorUtils from "../errors/ErrorUtils";
 import UnhandledErrorDispatcher from "../errors/UnhandledErrorDispatcher";
 import Cache from "./Cache";
 
@@ -266,7 +267,8 @@
         if (showSpinner) {
           decrementCount();
         }
-        if (!ignoreErrors) {
+        // Always report unauthenticated errors so user knows they need to re-authenticate
+        if (!ignoreErrors || ErrorUtils.isUnauthenticatedError(error)) {
           this.reportError(error);
         }
         throw error;
diff --git a/django_airavata/apps/api/tests/test_views.py b/django_airavata/apps/api/tests/test_views.py
index 22d30e7..201538d 100644
--- a/django_airavata/apps/api/tests/test_views.py
+++ b/django_airavata/apps/api/tests/test_views.py
@@ -468,3 +468,26 @@
         group_manager_mock.getGroup.assert_not_called()
         group_manager_mock.addUsersToGroup.assert_not_called()
         user_added_to_group_handler.assert_not_called()
+
+
+@override_settings(
+    GATEWAY_ID=GATEWAY_ID,
+    PORTAL_ADMINS=PORTAL_ADMINS
+)
+class ExceptionHandlingTest(TestCase):
+
+    def setUp(self):
+        self.user = User.objects.create_user('testuser')
+        self.factory = APIRequestFactory()
+
+    def test_unauthenticated_request(self):
+
+        url = reverse('django_airavata_api:group-list')
+        data = {}
+        request = self.factory.post(url, data)
+        # Deliberately not authenticating user for request
+        group_create = views.GroupViewSet.as_view({'post': 'create'})
+        response = group_create(request)
+        self.assertEquals(403, response.status_code)
+        self.assertIn('is_authenticated', response.data)
+        self.assertFalse(response.data['is_authenticated'])
diff --git a/django_airavata/static/common/js/components/NotificationsDisplay.vue b/django_airavata/static/common/js/components/NotificationsDisplay.vue
index 1954218..b711344 100644
--- a/django_airavata/static/common/js/components/NotificationsDisplay.vue
+++ b/django_airavata/static/common/js/components/NotificationsDisplay.vue
@@ -1,16 +1,36 @@
 <template>
   <div id="notifications-display">
     <transition-group name="fade" tag="div">
-      <b-alert
-        v-for="unhandledError in unhandledErrors"
-        variant="danger"
-        :key="unhandledError.id"
-        show
-        dismissible
-        @dismissed="dismissUnhandledError(unhandledError)"
-      >
-        {{ unhandledError.message }}
-      </b-alert>
+      <template v-for="unhandledError in unhandledErrors">
+        <b-alert
+          v-if="isUnauthenticatedError(unhandledError.error)"
+          variant="warning"
+          :key="unhandledError.id"
+          show
+          dismissible
+          @dismissed="dismissUnhandledError(unhandledError)"
+        >
+          Your login session has expired. Please
+          <b-link class="alert-link" :href="loginLinkWithNext"
+            >log in again</b-link
+          >. You can also
+          <b-link class="alert-link" :href="loginLink" target="_blank"
+            >login in a separate tab
+            <i class="fa fa-external-link-alt" aria-hidden="true"></i
+          ></b-link>
+          and then return to this tab and try again.
+        </b-alert>
+        <b-alert
+          v-else
+          variant="danger"
+          :key="unhandledError.id"
+          show
+          dismissible
+          @dismissed="dismissUnhandledError(unhandledError)"
+        >
+          {{ unhandledError.message }}
+        </b-alert>
+      </template>
       <b-alert
         v-for="notification in notifications"
         :variant="variant(notification)"
@@ -86,6 +106,9 @@
       }.bind(this);
       setTimeout(pollAPIServerStatus.bind(this), this.pollingDelay);
     },
+    isUnauthenticatedError(error) {
+      return errors.ErrorUtils.isUnauthenticatedError(error);
+    },
   },
   computed: {
     apiServerDown() {
@@ -130,6 +153,12 @@
         : false;
       return notificationsApiServerDown || unhandledErrorsApiServerDown;
     },
+    loginLinkWithNext() {
+      return errors.ErrorUtils.buildLoginUrl();
+    },
+    loginLink() {
+      return errors.ErrorUtils.buildLoginUrl(false);
+    },
   },
   watch: {
     /*
diff --git a/docs/tutorial/custom_ui_tutorial.md b/docs/tutorial/custom_ui_tutorial.md
index 9d2782b..df88322 100644
--- a/docs/tutorial/custom_ui_tutorial.md
+++ b/docs/tutorial/custom_ui_tutorial.md
@@ -1199,7 +1199,7 @@
 greetings in several languages.
 
 1. In the `$HOME/custom_ui_tutorial_app/custom_ui_tutorial_app/views.py` file,
-   we add the following import:
+   we add the following imports:
 
 <button class="btn" data-clipboard-target="#jsonresponse">
     Copy to clipboard
@@ -1208,6 +1208,7 @@
 
 ```python
 from django.http import JsonResponse
+from rest_framework.decorators import api_view
 ```
 
 </div>
@@ -1220,7 +1221,7 @@
 <div id="languages">
 
 ```python
-@login_required
+@api_view()
 def languages(request):
     return JsonResponse({'languages': [{
         'lang': 'French',
diff --git a/tests/settings.py b/tests/settings.py
index 7bc12f0..e65a3bc 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -10,7 +10,10 @@
 DATABASES = {
     'default': {
         'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': os.path.join(BASEDIR, 'db.sqlite3'),
+        'TEST': {
+            'ENGINE': 'django.db.backends.sqlite3',
+            'NAME': os.path.join(BASEDIR, 'test-db.sqlite3'),
+        }
     }
 }