Skip to content

Commit

Permalink
Merge pull request #837 from yeatmanlab/ref/318/query-composables-sso
Browse files Browse the repository at this point in the history
Combine `ClassLink` and `Clever` into single refactored SSO landing page
  • Loading branch information
maximilianoertel authored Oct 2, 2024
2 parents c839bfc + 97b0798 commit 3132898
Show file tree
Hide file tree
Showing 14 changed files with 427 additions and 68 deletions.
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"dompurify": "^3.1.6",
"dotenv": "^16.3.1",
"html2canvas": "^1.4.1",
"http-status-codes": "^2.3.0",
"jspdf": "^2.5.1",
"lodash": "^4.17.21",
"marked": "^7.0.3",
Expand Down
6 changes: 6 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,20 @@ onUpdated(async () => {
onBeforeMount(async () => {
await authStore.initFirekit();
authStore.setUser();
await authStore.initStateFromRedirect().then(async () => {
// @TODO: Refactor this callback as we should ideally use the useUserClaimsQuery and useUserDataQuery composables.
// @NOTE: Whilst the rest of the application relies on the user's ROAR UID, this callback requires the user's ID
// in order for SSO to work and cannot currently be changed without significant refactoring.
if (authStore.uid) {
const userData = await fetchDocById('users', authStore.uid);
const userClaims = await fetchDocById('userClaims', authStore.uid);
authStore.userData = userData;
authStore.userClaims = userClaims;
}
});
isAuthStoreReady.value = true;
});
Expand Down
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
11 changes: 6 additions & 5 deletions src/components/views/LinkAccountsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@
<button
v-if="providerIds.includes('oidc.clever')"
class="border-none border-round bg-primary text-white p-2 my-2 hover:surface-400 mr-2"
@click="unlinkAccount('clever')"
@click="unlinkAccount(AUTH_SSO_PROVIDERS.CLEVER)"
>
Unlink
</button>
<button
v-else
class="border-none border-round bg-primary text-white p-2 my-2 hover:surface-400 mr-2"
@click="linkAccount('clever')"
@click="linkAccount(AUTH_SSO_PROVIDERS.CLEVER)"
>
Link
</button>
Expand All @@ -65,14 +65,14 @@
<button
v-if="providerIds.includes('oidc.classlink')"
class="border-none border-round bg-primary text-white p-2 my-2 hover:surface-400 mr-2"
@click="unlinkAccount('classlink')"
@click="unlinkAccount(AUTH_SSO_PROVIDERS.CLASSLINK)"
>
Unlink
</button>
<button
v-else
class="border-none border-round bg-primary text-white p-2 my-2 hover:surface-400 mr-2"
@click="linkAccount('classlink')"
@click="linkAccount(AUTH_SSO_PROVIDERS.CLASSLINK)"
>
Link
</button>
Expand All @@ -98,10 +98,11 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useAuthStore } from '@/store/auth';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/store/auth';
import { AUTH_SSO_PROVIDERS } from '@/constants/auth';
// +----------------+
// | Initialization |
Expand Down
117 changes: 117 additions & 0 deletions src/composables/useSSOAccountReadinessVerification.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { ref, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router';
import { useQueryClient } from '@tanstack/vue-query';
import { StatusCodes } from 'http-status-codes';
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 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: Implement a MAX_RETRY_COUNT to prevent infinite polling, incl. a redirect to an error page.
* @TODO: Consider refactoring this function to leverage an alternative mechanism such as realtime updates from
* Firestore instead of the current polling logic.
*/
const useSSOAccountReadinessVerification = () => {
const retryCount = ref(1);
let userDataCheckInterval = null;

const router = useRouter();
const queryClient = useQueryClient();

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(`[SSO] User type missing for user ${roarUid.value}. Attempt #${retryCount.value}, retrying...`);
retryCount.value++;
return;
}

if (userType === AUTH_USER_TYPE.GUEST) {
console.log(
`[SSO] User ${roarUid.value} identified as ${userType} user. Attempt #${retryCount.value}, retrying...`,
);
retryCount.value++;
return;
}

console.log(`[SSO] 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 the error is a 401, we assume the backend is still processing the user document setup and we should retry.
if (error.status == StatusCodes.UNAUTHORIZED) return;

// Otherwise throw the error as it's unexpected.
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,
};
};

export default useSSOAccountReadinessVerification;
Loading

0 comments on commit 3132898

Please sign in to comment.