Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Combine ClassLink and Clever into single refactored SSO landing page #837

Merged
4 changes: 2 additions & 2 deletions src/components/CardAdministration.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -193,14 +193,14 @@ 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',
summary: 'Confirmed',
detail: `Deleted administration ${props.title}`,
life: 3000,
});
administrationQueryKeyIndex.value += 1;
});
},
reject: () => {
Expand Down
111 changes: 111 additions & 0 deletions src/composables/useSSOAccountReadinessVerification.js
Original file line number Diff line number Diff line change
@@ -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<void>}
* @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,
};
}
16 changes: 14 additions & 2 deletions src/constants/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,20 @@ 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',
};
});

/**
* Auth SSO Providers
*
* @constant {Object} AUTH_SSO_PROVIDERS - The sources of SSO authentication.
*/
export const AUTH_SSO_PROVIDERS = Object.freeze({
CLEVER: 'clever',
CLASSLINK: 'classlink',
});
1 change: 1 addition & 0 deletions src/constants/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
66 changes: 0 additions & 66 deletions src/pages/ClassLinkLanding.vue

This file was deleted.

66 changes: 0 additions & 66 deletions src/pages/CleverLanding.vue

This file was deleted.

12 changes: 5 additions & 7 deletions src/pages/HomeSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,24 @@ 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'));
const ConsentModal = defineAsyncComponent(() => import('@/components/ConsentModal.vue'));

const isLevante = import.meta.env.MODE === 'LEVANTE';
const authStore = useAuthStore();
const { roarfirekit, authFromClever, authFromClassLink } = storeToRefs(authStore);
const { roarfirekit, ssoProvider } = 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 (ssoProvider.value) {
console.log('Detected SSO authentication, redirecting...');
router.replace({ path: APP_ROUTES.SSO });
}

const gameStore = useGameStore();
Expand Down
42 changes: 42 additions & 0 deletions src/pages/SSOAuthPage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<template>
<div class="w-full py-8 text-center">
<AppSpinner style="margin-bottom: 1rem" />
<span v-if="isClassLinkProvider">{{ $t('classLinkLanding.classLinkLoading') }}</span>
<span v-if="isCleverProvider">{{ $t('cleverLanding.cleverLoading') }}</span>
</div>
</template>
<script setup>
import { computed, onBeforeMount, onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/store/auth';
import useSSOAccountReadinessVerification from '@/composables/useSSOAccountReadinessVerification';
import AppSpinner from '@/components/AppSpinner.vue';
import { APP_ROUTES } from '@/constants/routes';
import { AUTH_SSO_PROVIDERS } from '../constants/auth';

const router = useRouter();
const authStore = useAuthStore();
const { roarUid, ssoProvider } = storeToRefs(authStore);

const { startPolling } = useSSOAccountReadinessVerification(roarUid.value);

const isClassLinkProvider = computed(() => ssoProvider.value === AUTH_SSO_PROVIDERS.CLASSLINK);
const isCleverProvider = computed(() => ssoProvider.value === AUTH_SSO_PROVIDERS.CLEVER);

onBeforeMount(() => {
if (!ssoProvider.value) {
console.error('No SSO provider detected. Redirecting to homepage...');
router.push({ path: APP_ROUTES.HOME });
return;
}
});

onMounted(() => {
console.log(`User ${roarUid.value} was redirected to SSO landing page from ${ssoProvider.value}`);
console.log('Polling for account readiness...');

ssoProvider.value = null;
startPolling();
});
</script>
Loading
Loading