From 191c66d535f8c21c0b5b6cc6185d22b510646201 Mon Sep 17 00:00:00 2001 From: Maximilian Oertel Date: Fri, 27 Sep 2024 23:18:31 +0100 Subject: [PATCH 1/8] Combine ClassLink and Clever into single SSO landing page --- src/components/CardAdministration.vue | 4 +- .../useSSOAccountReadinessVerification.js | 111 ++++++++++++++++++ src/constants/auth.js | 6 +- src/constants/routes.js | 1 + src/pages/ClassLinkLanding.vue | 66 ----------- src/pages/CleverLanding.vue | 66 ----------- src/pages/HomeSelector.vue | 12 +- src/pages/SSOAuthPage.vue | 48 ++++++++ src/pages/SignIn.vue | 16 ++- src/router/index.js | 21 ++-- src/store/auth.js | 32 +++-- 11 files changed, 201 insertions(+), 182 deletions(-) create mode 100644 src/composables/useSSOAccountReadinessVerification.js delete mode 100644 src/pages/ClassLinkLanding.vue delete mode 100644 src/pages/CleverLanding.vue create mode 100644 src/pages/SSOAuthPage.vue diff --git a/src/components/CardAdministration.vue b/src/components/CardAdministration.vue index 38e3a1569..09b08919e 100644 --- a/src/components/CardAdministration.vue +++ b/src/components/CardAdministration.vue @@ -166,7 +166,7 @@ import { SINGULAR_ORG_TYPES } from '@/constants/orgTypes'; const router = useRouter(); const authStore = useAuthStore(); -const { roarfirekit, administrationQueryKeyIndex, tasksDictionary } = storeToRefs(authStore); +const { roarfirekit, tasksDictionary } = storeToRefs(authStore); const props = defineProps({ id: { type: String, required: true }, @@ -193,6 +193,7 @@ const speedDialItems = ref([ message: 'Are you sure you want to delete this administration?', icon: 'pi pi-exclamation-triangle', accept: async () => { + // @TODO: Move to mutation as we cannot rotate query key indexes anymore. await roarfirekit.value.deleteAdministration(props.id).then(() => { toast.add({ severity: 'info', @@ -200,7 +201,6 @@ const speedDialItems = ref([ detail: `Deleted administration ${props.title}`, life: 3000, }); - administrationQueryKeyIndex.value += 1; }); }, reject: () => { diff --git a/src/composables/useSSOAccountReadinessVerification.js b/src/composables/useSSOAccountReadinessVerification.js new file mode 100644 index 000000000..b1964de9f --- /dev/null +++ b/src/composables/useSSOAccountReadinessVerification.js @@ -0,0 +1,111 @@ +import { ref, onUnmounted } from 'vue'; +import { storeToRefs } from 'pinia'; +import { useRouter } from 'vue-router'; +import { useQueryClient } from '@tanstack/vue-query'; +import { useAuthStore } from '@/store/auth.js'; +import useUserDataQuery from '@/composables/queries/useUserDataQuery'; +import { AUTH_USER_TYPE } from '@/constants/auth'; +import { APP_ROUTES } from '@/constants/routes'; + +const router = useRouter(); +const queryClient = useQueryClient(); + +const POLLING_INTERVAL = 600; + +/** + * Verify account readiness after SSO authentication. + * + * This composable is an abstraction of the currently implemented logic in the SSO callback pages. Following SSO + * authentication, the application's backend creates a user document and populates it in the database. As this document + * is required before being able to utilize the application, this composable is designed to poll the user document until + * it is ready for use and then redirect the user to the home page. + * + * @TODO: Check what "guest" user type means? + * @TODO: Check if we can fetch the user type from the userClaims query instead of the user document? + * @TODO: Check why we're only throwing an error if the error code is not 'ERR_BAD_REQUEST'? + * @TODO: Consider refactoring this function to leverage realtime updates from Firestore instead of polling. + */ +export default function useSSOAccountReadinessVerification() { + const retryCount = ref(0); + let userDataCheckInterval = null; + + const authStore = useAuthStore(); + const { roarUid } = storeToRefs(authStore); + + const { data: userData, refetch: refetchUserData, isFetchedAfterMount } = useUserDataQuery(); + + /** + * Verify account readiness after SSO authentication. + * + * This function checks if the user type is both available and the expected value. This function is called as part of + * a polling mechanism whilst we await for the user document to be created and ready for use following single sign-on. + * + * @returns {Promise} + * @throws {Error} Throws any but ERR_BAD_REQUEST errors. + */ + const verifyAccountReadiness = async () => { + try { + // Skip the first fetch after mount as data is fetched on mount in the useUserDataQuery composable. + if (!isFetchedAfterMount.value) { + await refetchUserData(); + } + + const userType = userData?.value?.userType; + + if (!userType) { + console.log(`User type missing for user ${roarUid.value}. Attempt #${retryCount.value}, retrying...`); + retryCount.value++; + return; + } + + if (userType === AUTH_USER_TYPE.GUEST) { + console.log(`User ${roarUid.value} identified as ${userType} user. Attempt #${retryCount.value}, retrying...`); + retryCount.value++; + return; + } + + console.log(`User ${roarUid.value} successfully identified as ${userType} user. Routing to home page...`); + + // Stop the polling mechanism. + clearInterval(userDataCheckInterval); + + // Invalidate all queries to ensure data is fetched freshly after the user document is ready. + // @TODO: Check if this is actually necessary and if so, if we should only invalidate specific queries. + queryClient.invalidateQueries(); + + // Redirect to the home page. + router.push({ path: APP_ROUTES.HOME }); + } catch (error) { + if (error.code !== 'ERR_BAD_REQUEST') { + throw error; + } + } + }; + + /** + * Starts polling to check for the user type after SSO authentication. + * + * @returns {void} + */ + const startPolling = () => { + userDataCheckInterval = setInterval(verifyAccountReadiness, POLLING_INTERVAL); + }; + + /** + * Cleanup function to stop polling when the component is unmounted. + * + * @returns {void} + */ + const stopPolling = () => { + clearInterval(userDataCheckInterval); + }; + + onUnmounted(() => { + stopPolling(); + }); + + return { + retryCount, + startPolling, + }; +} diff --git a/src/constants/auth.js b/src/constants/auth.js index eeb083eb5..da6e39e25 100644 --- a/src/constants/auth.js +++ b/src/constants/auth.js @@ -16,8 +16,10 @@ export const AUTH_SESSION_TIMEOUT_COUNTDOWN_DURATION = * * @constant {Object} AUTH_USER_TYPE - User type, admin or participant. */ -export const AUTH_USER_TYPE = { +export const AUTH_USER_TYPE = Object.freeze({ ADMIN: 'admin', + GUEST: 'guest', PARTICIPANT: 'participant', + STUDENT: 'student', SUPER_ADMIN: 'super-admin', -}; +}); diff --git a/src/constants/routes.js b/src/constants/routes.js index 2b2f3e691..ba0211a0a 100644 --- a/src/constants/routes.js +++ b/src/constants/routes.js @@ -9,6 +9,7 @@ export const APP_ROUTES = { HOME: '/', SIGN_IN: '/signin', + SSO: '/sso', PROGRESS_REPORT: '/administration/:administrationId/:orgType/:orgId', SCORE_REPORT: '/scores/:administrationId/:orgType/:orgId', STUDENT_REPORT: '/scores/:administrationId/:orgType/:orgId/user/:userId', diff --git a/src/pages/ClassLinkLanding.vue b/src/pages/ClassLinkLanding.vue deleted file mode 100644 index 2e41435b3..000000000 --- a/src/pages/ClassLinkLanding.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/src/pages/CleverLanding.vue b/src/pages/CleverLanding.vue deleted file mode 100644 index c4995ae50..000000000 --- a/src/pages/CleverLanding.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/src/pages/HomeSelector.vue b/src/pages/HomeSelector.vue index 76519c0f7..2ab5a9ac1 100644 --- a/src/pages/HomeSelector.vue +++ b/src/pages/HomeSelector.vue @@ -32,6 +32,7 @@ import useUserDataQuery from '@/composables/queries/useUserDataQuery'; import useUserClaimsQuery from '@/composables/queries/useUserClaimsQuery'; import useUpdateConsentMutation from '@/composables/mutations/useUpdateConsentMutation'; import { CONSENT_TYPES } from '@/constants/consentTypes'; +import { APP_ROUTES } from '@/constants/routes'; const HomeParticipant = defineAsyncComponent(() => import('@/pages/HomeParticipant.vue')); const HomeAdministrator = defineAsyncComponent(() => import('@/pages/HomeAdministrator.vue')); @@ -39,19 +40,16 @@ const ConsentModal = defineAsyncComponent(() => import('@/components/ConsentModa const isLevante = import.meta.env.MODE === 'LEVANTE'; const authStore = useAuthStore(); -const { roarfirekit, authFromClever, authFromClassLink } = storeToRefs(authStore); +const { roarfirekit, authFromSSO } = storeToRefs(authStore); const router = useRouter(); const i18n = useI18n(); const { mutateAsync: updateConsentStatus } = useUpdateConsentMutation(); -if (authFromClever.value) { - console.log('Detected Clever authentication, routing to CleverLanding page'); - router.push({ name: 'CleverLanding' }); -} else if (authFromClassLink.value) { - console.log('Detected ClassLink authentication, routing to ClassLinkLanding page'); - router.push({ name: 'ClassLinkLanding' }); +if (authFromSSO.value) { + console.log('Detected SSO authentication, redirecting...'); + router.replace({ path: APP_ROUTES.SSO }); } const gameStore = useGameStore(); diff --git a/src/pages/SSOAuthPage.vue b/src/pages/SSOAuthPage.vue new file mode 100644 index 000000000..1165d9b87 --- /dev/null +++ b/src/pages/SSOAuthPage.vue @@ -0,0 +1,48 @@ + + diff --git a/src/pages/SignIn.vue b/src/pages/SignIn.vue index ae4baa395..c3904d298 100644 --- a/src/pages/SignIn.vue +++ b/src/pages/SignIn.vue @@ -146,13 +146,14 @@ import { useAuthStore } from '@/store/auth'; import { isMobileBrowser } from '@/helpers'; import { fetchDocById } from '../helpers/query/utils'; import RoarModal from '../components/modals/RoarModal.vue'; +import { APP_ROUTES } from '@/constants/routes'; const incorrect = ref(false); const isLevante = import.meta.env.MODE === 'LEVANTE'; const authStore = useAuthStore(); const router = useRouter(); -const { spinner, authFromClever, authFromClassLink, routeToProfile, roarfirekit } = storeToRefs(authStore); +const { spinner, authFromSSO, routeToProfile, roarfirekit } = storeToRefs(authStore); const warningModalOpen = ref(false); authStore.$subscribe(() => { @@ -167,14 +168,12 @@ authStore.$subscribe(() => { } } - if (authFromClever.value) { - router.push({ name: 'CleverLanding' }); - } else if (authFromClassLink.value) { - router.push({ name: 'ClassLinkLanding' }); + if (authFromSSO.value) { + router.push({ path: APP_ROUTES.SSO }); } else if (routeToProfile.value) { - router.push({ name: 'ProfileAccounts' }); + router.push({ path: APP_ROUTES.ACCOUNT_PROFILE }); } else { - router.push({ name: 'Home' }); + router.push({ path: APP_ROUTES.HOME }); } } }); @@ -214,8 +213,8 @@ const modalPassword = ref(''); const authWithClever = () => { console.log('---> authWithClever'); authStore.signInWithCleverRedirect(); + // authStore.signInWithCleverPopup(); spinner.value = true; - // } }; const authWithClassLink = () => { @@ -225,7 +224,6 @@ const authWithClassLink = () => { spinner.value = true; } else { authStore.signInWithClassLinkRedirect(); - // authStore.signInWithCleverPopup(); spinner.value = true; } }; diff --git a/src/router/index.js b/src/router/index.js index e1d15d152..0f7142548 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -26,18 +26,6 @@ const routes = [ }, }, }, - { - path: '/clever-user', - name: 'CleverLanding', - component: () => import('../pages/CleverLanding.vue'), - meta: { pageTitle: 'Logging You In' }, - }, - { - path: '/classlink-user', - name: 'ClassLinkLanding', - component: () => import('../pages/ClassLinkLanding.vue'), - meta: { pageTitle: 'Logging You In' }, - }, { path: '/game/swr', name: 'SWR', @@ -246,6 +234,14 @@ const routes = [ }, }, }, + { + path: APP_ROUTES.SSO, + name: 'SSO', + beforeRouteLeave: [removeQueryParams, removeHash], + component: () => import('../pages/SSOAuthPage.vue'), + props: (route) => ({ code: route.query.code }), // @TODO: Isn't the code processed by the sign-in page? + meta: { pageTitle: 'Signing you in…' }, + }, { path: '/auth-clever', name: 'AuthClever', @@ -422,6 +418,7 @@ router.beforeEach(async (to, from, next) => { const allowedUnauthenticatedRoutes = [ 'SignIn', + 'SSO', //@TODO: Remove before merging 'Maintenance', 'AuthClever', 'AuthClassLink', diff --git a/src/store/auth.js b/src/store/auth.js index cadb40d6e..9518b4d65 100644 --- a/src/store/auth.js +++ b/src/store/auth.js @@ -23,11 +23,9 @@ export const useAuthStore = () => { cleverOAuthRequested: false, classLinkOAuthRequested: false, routeToProfile: false, + authFromSSO: false, authFromClever: false, authFromClassLink: false, - userQueryKeyIndex: 0, - assignmentQueryKeyIndex: 0, - administrationQueryKeyIndex: 0, tasksDictionary: {}, showOptionalAssessments: false, }; @@ -63,8 +61,8 @@ export const useAuthStore = () => { }, actions: { async completeAssessment(adminId, taskId) { + //@TODO: Move to mutation since we cannot rotate query keys anymore. await this.roarfirekit.completeAssessment(adminId, taskId); - this.assignmentQueryKeyIndex += 1; }, setUser() { onAuthStateChanged(this.roarfirekit?.admin.auth, async (user) => { @@ -137,26 +135,30 @@ export const useAuthStore = () => { return this.roarfirekit.signInWithPopup('google'); } }, + async signInWithGoogleRedirect() { + return this.roarfirekit.initiateRedirect('google'); + }, async signInWithCleverPopup() { + this.authFromSSO = true; this.authFromClever = true; if (this.isFirekitInit) { return this.roarfirekit.signInWithPopup('clever'); } }, + async signInWithCleverRedirect() { + this.authFromSSO = true; + this.authFromClever = true; + return this.roarfirekit.initiateRedirect('clever'); + }, async signInWithClassLinkPopup() { - this.authFromClasslink = true; + this.authFromSSO = true; + this.authFromClassLink = true; if (this.isFirekitInit) { return this.roarfirekit.signInWithPopup('classlink'); } }, - async signInWithGoogleRedirect() { - return this.roarfirekit.initiateRedirect('google'); - }, - async signInWithCleverRedirect() { - this.authFromClever = true; - return this.roarfirekit.initiateRedirect('clever'); - }, async signInWithClassLinkRedirect() { + this.authFromSSO = true; this.authFromClassLink = true; return this.roarfirekit.initiateRedirect('classlink'); }, @@ -180,12 +182,6 @@ export const useAuthStore = () => { async forceIdTokenRefresh() { await this.roarfirekit.forceIdTokenRefresh(); }, - refreshQueryKeys() { - // @TODO: Check if this manual query invalidation is necessary as this appears to cause unecessary refetching. - this.userQueryKeyIndex += 1; - this.assignmentQueryKeyIndex += 1; - this.administrationQueryKeyIndex += 1; - }, async sendMyPasswordResetEmail() { if (this.email) { return await this.roarfirekit.sendPasswordResetEmail(this.email).then(() => { From 647a9a54fa3c50710a5b2666c7106fe6e57125f7 Mon Sep 17 00:00:00 2001 From: Maximilian Oertel Date: Mon, 30 Sep 2024 17:33:23 +0100 Subject: [PATCH 2/8] Refactor store sso source --- src/constants/auth.js | 10 ++++++++++ src/pages/SSOAuthPage.vue | 26 ++++++++++---------------- src/pages/SignIn.vue | 4 ++-- src/store/auth.js | 17 ++++++----------- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/constants/auth.js b/src/constants/auth.js index da6e39e25..6d4d255f2 100644 --- a/src/constants/auth.js +++ b/src/constants/auth.js @@ -23,3 +23,13 @@ export const AUTH_USER_TYPE = Object.freeze({ STUDENT: 'student', SUPER_ADMIN: 'super-admin', }); + +/** + * Auth SSO Providers + * + * @constant {Object} AUTH_SSO_PROVIDERS - The sources of SSO authentication. + */ +export const AUTH_SSO_PROVIDERS = Object.freeze({ + CLEVER: 'clever', + CLASSLINK: 'classlink', +}); diff --git a/src/pages/SSOAuthPage.vue b/src/pages/SSOAuthPage.vue index 1165d9b87..a13488508 100644 --- a/src/pages/SSOAuthPage.vue +++ b/src/pages/SSOAuthPage.vue @@ -1,8 +1,8 @@ diff --git a/src/pages/SignIn.vue b/src/pages/SignIn.vue index c3904d298..22089e74b 100644 --- a/src/pages/SignIn.vue +++ b/src/pages/SignIn.vue @@ -153,7 +153,7 @@ const isLevante = import.meta.env.MODE === 'LEVANTE'; const authStore = useAuthStore(); const router = useRouter(); -const { spinner, authFromSSO, routeToProfile, roarfirekit } = storeToRefs(authStore); +const { spinner, ssoProvider, routeToProfile, roarfirekit } = storeToRefs(authStore); const warningModalOpen = ref(false); authStore.$subscribe(() => { @@ -168,7 +168,7 @@ authStore.$subscribe(() => { } } - if (authFromSSO.value) { + if (ssoProvider.value) { router.push({ path: APP_ROUTES.SSO }); } else if (routeToProfile.value) { router.push({ path: APP_ROUTES.ACCOUNT_PROFILE }); diff --git a/src/store/auth.js b/src/store/auth.js index 9518b4d65..6d0ca086b 100644 --- a/src/store/auth.js +++ b/src/store/auth.js @@ -5,6 +5,7 @@ import _isEmpty from 'lodash/isEmpty'; import _union from 'lodash/union'; import { initNewFirekit } from '../firebaseInit'; import { taskFetcher } from '../helpers/query/tasks.js'; +import { AUTH_SSO_PROVIDERS } from '../constants/auth'; export const useAuthStore = () => { return defineStore('authStore', { @@ -23,9 +24,7 @@ export const useAuthStore = () => { cleverOAuthRequested: false, classLinkOAuthRequested: false, routeToProfile: false, - authFromSSO: false, - authFromClever: false, - authFromClassLink: false, + ssoProvider: null, tasksDictionary: {}, showOptionalAssessments: false, }; @@ -139,27 +138,23 @@ export const useAuthStore = () => { return this.roarfirekit.initiateRedirect('google'); }, async signInWithCleverPopup() { - this.authFromSSO = true; - this.authFromClever = true; + this.ssoProvider = AUTH_SSO_PROVIDERS.CLEVER; if (this.isFirekitInit) { return this.roarfirekit.signInWithPopup('clever'); } }, async signInWithCleverRedirect() { - this.authFromSSO = true; - this.authFromClever = true; + this.ssoProvider = AUTH_SSO_PROVIDERS.CLEVER; return this.roarfirekit.initiateRedirect('clever'); }, async signInWithClassLinkPopup() { - this.authFromSSO = true; - this.authFromClassLink = true; + this.ssoProvider = AUTH_SSO_PROVIDERS.CLASSLINK; if (this.isFirekitInit) { return this.roarfirekit.signInWithPopup('classlink'); } }, async signInWithClassLinkRedirect() { - this.authFromSSO = true; - this.authFromClassLink = true; + this.ssoProvider = AUTH_SSO_PROVIDERS.CLASSLINK; return this.roarfirekit.initiateRedirect('classlink'); }, async initStateFromRedirect() { From 9cd4e435090175847487a035d4371cf764914146 Mon Sep 17 00:00:00 2001 From: Maximilian Oertel Date: Tue, 1 Oct 2024 09:44:40 +0100 Subject: [PATCH 3/8] Fix home selector routing for sso flow --- src/pages/HomeSelector.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/HomeSelector.vue b/src/pages/HomeSelector.vue index 2ab5a9ac1..e6f5e33dc 100644 --- a/src/pages/HomeSelector.vue +++ b/src/pages/HomeSelector.vue @@ -40,14 +40,14 @@ const ConsentModal = defineAsyncComponent(() => import('@/components/ConsentModa const isLevante = import.meta.env.MODE === 'LEVANTE'; const authStore = useAuthStore(); -const { roarfirekit, authFromSSO } = storeToRefs(authStore); +const { roarfirekit, ssoProvider } = storeToRefs(authStore); const router = useRouter(); const i18n = useI18n(); const { mutateAsync: updateConsentStatus } = useUpdateConsentMutation(); -if (authFromSSO.value) { +if (ssoProvider.value) { console.log('Detected SSO authentication, redirecting...'); router.replace({ path: APP_ROUTES.SSO }); } From 10809b883ebfa844734e041adfd671bd60b49bfa Mon Sep 17 00:00:00 2001 From: Maximilian Oertel Date: Tue, 1 Oct 2024 17:01:35 +0100 Subject: [PATCH 4/8] Use AUTH_SSO_PROVIDERS instead of string literals --- src/components/views/LinkAccountsView.vue | 11 ++++++----- src/constants/auth.js | 1 + src/pages/SignIn.vue | 11 ++++++----- src/store/auth.js | 12 ++++++------ 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/components/views/LinkAccountsView.vue b/src/components/views/LinkAccountsView.vue index 702f70bc5..a2eaf79c6 100644 --- a/src/components/views/LinkAccountsView.vue +++ b/src/components/views/LinkAccountsView.vue @@ -40,14 +40,14 @@ @@ -65,14 +65,14 @@ @@ -98,10 +98,11 @@