From 637c380c0768f849fbad280b3ae2e5964b787c30 Mon Sep 17 00:00:00 2001 From: Chhavi Goyal Date: Mon, 22 Sep 2025 09:36:40 -0400 Subject: [PATCH] fix: handle actions for logged-in users in case of token expiration (#5249) Co-authored-by: nivedin Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com> --- .../src/auth/auth.controller.ts | 10 ++ .../components/collections/graphql/Add.vue | 1 + .../collections/graphql/Collection.vue | 5 +- .../components/collections/graphql/Edit.vue | 6 +- .../collections/graphql/EditFolder.vue | 5 +- .../collections/graphql/EditRequest.vue | 8 +- .../components/collections/graphql/Folder.vue | 5 +- .../collections/graphql/Request.vue | 5 +- .../components/collections/graphql/index.vue | 38 ++++-- .../src/components/collections/index.vue | 124 +++++++++++++----- .../environments/my/Environment.vue | 24 +++- .../src/components/environments/my/index.vue | 17 ++- .../components/environments/teams/index.vue | 5 +- .../src/helpers/handleTokenValidation.ts | 19 +++ .../src/helpers/isValidUser.ts | 44 +++++++ .../hoppscotch-common/src/platform/auth.ts | 6 + .../src/platform/auth.ts | 27 ++++ .../src/platform/auth/desktop/index.ts | 26 ++++ .../src/platform/auth/web/index.ts | 23 ++++ 19 files changed, 340 insertions(+), 58 deletions(-) create mode 100644 packages/hoppscotch-common/src/helpers/handleTokenValidation.ts create mode 100644 packages/hoppscotch-common/src/helpers/isValidUser.ts diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index 10c9be9c..c713dad5 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -216,4 +216,14 @@ export class AuthController { return tokens.right; } + + @Get('verify-token') + @UseGuards(JwtAuthGuard) + async verifyToken(@GqlUser() user: AuthUser) { + return { + isValid: true, + uid: user.uid, + message: 'Token is valid', + }; + } } diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Add.vue b/packages/hoppscotch-common/src/components/collections/graphql/Add.vue index 2d6e8baa..a5b8d988 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Add.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Add.vue @@ -69,6 +69,7 @@ const addNewCollection = () => { authActive: true, }, headers: [], + variables: [], }) ) diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue index b92624cc..88a50aa0 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue @@ -255,6 +255,7 @@ import { useService } from "dioc/vue" import { computed, ref } from "vue" import { Picked } from "~/helpers/types/HoppPicked" import { removeGraphqlCollection } from "~/newstore/collections" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" import { GQLTabService } from "~/services/tab/graphql" import IconCheckCircle from "~icons/lucide/check-circle" import IconCopy from "~icons/lucide/copy" @@ -358,7 +359,9 @@ const toggleShowChildren = () => { showChildren.value = !showChildren.value } -const removeCollection = () => { +const removeCollection = async () => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return // Cancel pick if picked collection is deleted if ( props.picked?.pickedType === "gql-my-collection" && diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Edit.vue b/packages/hoppscotch-common/src/components/collections/graphql/Edit.vue index 0c9c9fdd..8018dd00 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Edit.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Edit.vue @@ -38,6 +38,7 @@ import { editGraphqlCollection } from "~/newstore/collections" import { useToast } from "@composables/toast" import { useI18n } from "@composables/i18n" import { HoppCollection } from "@hoppscotch/data" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" const props = defineProps<{ show: boolean @@ -62,12 +63,15 @@ watch( } ) -const saveCollection = () => { +const saveCollection = async () => { if (!editingName.value) { toast.error(`${t("collection.invalid_name")}`) return } + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + // TODO: Better typechecking here ? const collectionUpdated = { ...(props.editingCollection as any), diff --git a/packages/hoppscotch-common/src/components/collections/graphql/EditFolder.vue b/packages/hoppscotch-common/src/components/collections/graphql/EditFolder.vue index f9aef64a..e668113d 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/EditFolder.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/EditFolder.vue @@ -37,6 +37,7 @@ import { ref, watch } from "vue" import { useI18n } from "@composables/i18n" import { useToast } from "@composables/toast" import { editGraphqlFolder } from "~/newstore/collections" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" const t = useI18n() const toast = useToast() @@ -59,11 +60,13 @@ watch( } ) -const editFolder = () => { +const editFolder = async () => { if (!name.value) { toast.error(`${t("collection.invalid_name")}`) return } + const isValidToken = await handleTokenValidation() + if (!isValidToken) return editGraphqlFolder(props.folderPath, { ...(props.folder as any), name: name.value, diff --git a/packages/hoppscotch-common/src/components/collections/graphql/EditRequest.vue b/packages/hoppscotch-common/src/components/collections/graphql/EditRequest.vue index cb122671..98519a05 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/EditRequest.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/EditRequest.vue @@ -108,6 +108,7 @@ import { GQLTabService } from "~/services/tab/graphql" import IconSparkle from "~icons/lucide/sparkles" import IconThumbsUp from "~icons/lucide/thumbs-up" import IconThumbsDown from "~icons/lucide/thumbs-down" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" const t = useI18n() const toast = useToast() @@ -155,12 +156,15 @@ watch( const submittedFeedback = ref(false) const { submitFeedback, isSubmitFeedbackPending } = useSubmitFeedback() -const saveRequest = () => { +const saveRequest = async () => { if (!editingName.value) { toast.error(`${t("collection.invalid_name")}`) return } + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + const requestUpdated = { ...(props.request as any), name: editingName.value || (props.request as any).name, @@ -173,7 +177,7 @@ const saveRequest = () => { folderPath: props.folderPath!, }) - editGraphqlRequest(props.folderPath, props.requestIndex, requestUpdated) + editGraphqlRequest(props.folderPath!, props.requestIndex!, requestUpdated) if (possibleActiveTab) { possibleActiveTab.value.document.request.name = requestUpdated.name diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue b/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue index ea82133f..234d08b1 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue @@ -237,6 +237,7 @@ import { useToast } from "@composables/toast" import { HoppCollection } from "@hoppscotch/data" import { useService } from "dioc/vue" import { computed, ref } from "vue" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" import { Picked } from "~/helpers/types/HoppPicked" import { removeGraphqlFolder } from "~/newstore/collections" import { GQLTabService } from "~/services/tab/graphql" @@ -322,7 +323,9 @@ const toggleShowChildren = () => { showChildren.value = !showChildren.value } -const removeFolder = () => { +const removeFolder = async () => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return // Cancel pick if the picked folder is deleted if ( props.picked?.pickedType === "gql-my-folder" && diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Request.vue b/packages/hoppscotch-common/src/components/collections/graphql/Request.vue index aa207503..fe0b3ba2 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Request.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Request.vue @@ -139,6 +139,7 @@ import { useI18n } from "@composables/i18n" import { useToast } from "@composables/toast" import { HoppGQLRequest } from "@hoppscotch/data" import { removeGraphqlRequest } from "~/newstore/collections" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" import { useService } from "dioc/vue" import { GQLTabService } from "~/services/tab/graphql" @@ -221,7 +222,9 @@ const dragStart = ({ dataTransfer }: any) => { dataTransfer.setData("requestIndex", props.requestIndex) } -const removeRequest = () => { +const removeRequest = async () => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return // Cancel pick if the picked request is deleted if ( props.picked && diff --git a/packages/hoppscotch-common/src/components/collections/graphql/index.vue b/packages/hoppscotch-common/src/components/collections/graphql/index.vue index 7894e814..e4497a5a 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/index.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/index.vue @@ -196,6 +196,7 @@ import { PersistedOAuthConfig } from "~/services/oauth/oauth.service" import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue" import { EditingProperties } from "../Properties.vue" import { defineActionHandler } from "~/helpers/actions" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" const t = useI18n() const toast = useToast() @@ -333,7 +334,9 @@ const filteredCollections = computed(() => { return filteredCollections }) -const displayModalAdd = (shouldDisplay: boolean) => { +const displayModalAdd = async (shouldDisplay: boolean) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return showModalAdd.value = shouldDisplay } @@ -343,7 +346,9 @@ const displayModalEdit = (shouldDisplay: boolean) => { if (!shouldDisplay) resetSelectedData() } -const displayModalImportExport = (shouldDisplay: boolean) => { +const displayModalImportExport = async (shouldDisplay: boolean) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return showModalImportExport.value = shouldDisplay } @@ -386,15 +391,21 @@ const editCollection = ( displayModalEdit(true) } -const duplicateCollection = ({ +const duplicateCollection = async ({ path, collectionSyncID, }: { path: string collectionSyncID?: string -}) => duplicateGraphQLCollection(path, collectionSyncID) +}) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + duplicateGraphQLCollection(path, collectionSyncID) +} -const onAddRequest = ({ name, path }: { name: string; path: string }) => { +const onAddRequest = async ({ name, path }: { name: string; path: string }) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return const newRequest = { ...getDefaultGQLRequest(), name, @@ -429,13 +440,15 @@ const addRequest = (payload: { path: string }) => { displayModalAddRequest(true) } -const onAddFolder = ({ +const onAddFolder = async ({ name, path, }: { name: string path: string | undefined }) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return addGraphqlFolder(name, path ?? "0") platform.analytics?.logEvent({ @@ -489,13 +502,15 @@ const editRequest = (payload: { displayModalEditRequest(true) } -const duplicateRequest = ({ +const duplicateRequest = async ({ folderPath, request, }: { folderPath: string request: HoppGQLRequest }) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return saveGraphqlRequestAs(folderPath, { ...cloneDeep(request), name: `${request.name} - ${t("action.duplicate")}`, @@ -536,7 +551,7 @@ const selectRequest = ({ }) } -const dropRequest = ({ +const dropRequest = async ({ folderPath, requestIndex, collectionIndex, @@ -545,6 +560,9 @@ const dropRequest = ({ requestIndex: number collectionIndex: number }) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + const possibleTab = tabs.getTabRefWithSaveContext({ originLocation: "user-collection", folderPath, @@ -607,11 +625,13 @@ const editProperties = ({ displayModalEditProperties(true) } -const setCollectionProperties = (newCollection: { +const setCollectionProperties = async (newCollection: { collection: Partial | null path: string isRootCollection: boolean }) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return const { collection, path, isRootCollection } = newCollection if (!collection) { return diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index 5431675f..1bbc59b5 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -49,7 +49,9 @@ @drop-request="dropRequest" @drop-collection="dropCollection" @display-modal-add="displayModalAdd(true)" - @display-modal-import-export="displayModalImportExport(true)" + @display-modal-import-export=" + displayModalImportExport(true, 'my-collections') + " @duplicate-collection="duplicateCollection" @duplicate-request="duplicateRequest" @duplicate-response="duplicateResponse" @@ -246,6 +248,7 @@ import { getCompleteCollectionTree, teamCollToHoppRESTColl, } from "~/helpers/backend/helpers" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" import { createChildCollection, createNewRootCollection, @@ -381,6 +384,7 @@ const currentUser = useReadonlyStream( platform.auth.getCurrentUserStream(), platform.auth.getCurrentUser() ) + const myCollections = useReadonlyStream(restCollections$, [], "deep") // Dragging @@ -755,7 +759,14 @@ const displayModalEditResponse = (show: boolean) => { if (!show) resetSelectedData() } -const displayModalImportExport = (show: boolean) => { +const displayModalImportExport = async ( + show: boolean, + collectionType?: string +) => { + if (collectionType === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + } showModalImportExport.value = show if (!show) resetSelectedData() @@ -779,8 +790,14 @@ const displayTeamModalAdd = (show: boolean) => { teamListAdapter.fetchList() } -const addNewRootCollection = (name: string) => { +const addNewRootCollection = async (name: string) => { if (collectionsType.value.type === "my-collections") { + modalLoadingState.value = true + const isValidToken = await handleTokenValidation() + if (!isValidToken) { + modalLoadingState.value = false + return + } addRESTCollection( makeCollection({ name, @@ -802,6 +819,7 @@ const addNewRootCollection = (name: string) => { isRootCollection: true, }) + modalLoadingState.value = false displayModalAdd(false) } else if (hasTeamWriteAccess.value) { if (!collectionsType.value.selectedTeam) return @@ -841,7 +859,7 @@ const addRequest = (payload: { displayModalAddRequest(true) } -const onAddRequest = (requestName: string) => { +const onAddRequest = async (requestName: string) => { const newRequest = { ...getDefaultRESTRequest(), name: requestName, @@ -850,6 +868,9 @@ const onAddRequest = (requestName: string) => { const path = editingFolderPath.value if (!path) return if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + const insertionIndex = saveRESTRequestAs(path, newRequest) tabs.createNewTab({ @@ -935,11 +956,13 @@ const addFolder = (payload: { displayModalAddFolder(true) } -const onAddFolder = (folderName: string) => { +const onAddFolder = async (folderName: string) => { const path = editingFolderPath.value if (collectionsType.value.type === "my-collections") { if (!path) return + const isValidToken = await handleTokenValidation() + if (!isValidToken) return addRESTFolder(folderName, path) platform.analytics?.logEvent({ @@ -1000,7 +1023,7 @@ const editCollection = (payload: { displayModalEditCollection(true) } -const updateEditingCollection = (newName: string) => { +const updateEditingCollection = async (newName: string) => { if (!editingCollection.value) return if (!newName) { @@ -1009,6 +1032,8 @@ const updateEditingCollection = (newName: string) => { } if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return const collectionIndex = editingCollectionIndex.value if (collectionIndex === null) return @@ -1058,10 +1083,12 @@ const editFolder = (payload: { displayModalEditFolder(true) } -const updateEditingFolder = (newName: string) => { +const updateEditingFolder = async (newName: string) => { if (!editingFolder.value) return if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return if (!editingFolderPath.value) return editRESTFolder(editingFolderPath.value, { @@ -1104,6 +1131,8 @@ const duplicateCollection = async ({ collectionSyncID?: string }) => { if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return duplicateRESTCollection(pathOrID, collectionSyncID) } else if (hasTeamWriteAccess.value) { duplicateCollectionLoading.value = true @@ -1141,7 +1170,7 @@ const editRequest = (payload: { displayModalEditRequest(true) } -const updateEditingRequest = (newName: string) => { +const updateEditingRequest = async (newName: string) => { const request = editingRequest.value if (!request) return @@ -1150,6 +1179,9 @@ const updateEditingRequest = (newName: string) => { name: newName || request.name, } if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + const folderPath = editingFolderPath.value const requestIndex = editingRequestIndex.value @@ -1295,12 +1327,15 @@ const updateEditingResponse = (newName: string) => { possibleExampleActiveTab.value.document.response.name = newName nextTick(() => { - possibleExampleActiveTab.value.document.isDirty = false - possibleExampleActiveTab.value.document.saveContext = { - originLocation: "user-collection", - folderPath: folderPath, - requestIndex: requestIndex, - exampleID: editingResponseID.value!, + const doc = possibleExampleActiveTab.value.document + if (doc.type === "example-response") { + doc.isDirty = false + doc.saveContext = { + originLocation: "user-collection", + folderPath: folderPath, + requestIndex: requestIndex, + exampleID: editingResponseID.value!, + } } }) } @@ -1361,11 +1396,14 @@ const updateEditingResponse = (newName: string) => { ) { possibleActiveResponseTab.value.document.response.name = newName nextTick(() => { - possibleActiveResponseTab.value.document.isDirty = false - possibleActiveResponseTab.value.document.saveContext = { - originLocation: "team-collection", - requestID, - exampleID: editingResponseID.value!, + const doc = possibleActiveResponseTab.value.document + if (doc.type === "example-response") { + doc.isDirty = false + doc.saveContext = { + originLocation: "team-collection", + requestID, + exampleID: editingResponseID.value!, + } } }) } @@ -1381,7 +1419,7 @@ const updateEditingResponse = (newName: string) => { } } -const duplicateRequest = (payload: { +const duplicateRequest = async (payload: { folderPath: string request: HoppRESTRequest }) => { @@ -1394,6 +1432,8 @@ const duplicateRequest = (payload: { } if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return saveRESTRequestAs(folderPath, newRequest) toast.success(t("request.duplicated")) } else if (hasTeamWriteAccess.value) { @@ -1424,7 +1464,7 @@ const duplicateRequest = (payload: { } } -const duplicateResponse = (payload: ResponseConfigPayload) => { +const duplicateResponse = async (payload: ResponseConfigPayload) => { const { folderPath, requestIndex, request, responseName } = payload const response = request.responses[responseName] @@ -1453,6 +1493,8 @@ const duplicateResponse = (payload: ResponseConfigPayload) => { } if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return editRESTRequest(folderPath, parseInt(requestIndex), updatedRequest) toast.success(t("response.duplicated")) @@ -1544,8 +1586,10 @@ const removeTeamCollectionOrFolder = async (collectionID: string) => { )() } -const onRemoveCollection = () => { +const onRemoveCollection = async () => { if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return const collectionIndex = editingCollectionIndex.value const collectionToRemove = @@ -1625,8 +1669,10 @@ const removeFolder = (id: string) => { displayConfirmModal(true) } -const onRemoveFolder = () => { +const onRemoveFolder = async () => { if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return const folderPath = editingFolderPath.value if (!folderPath) return @@ -1712,8 +1758,10 @@ const removeRequest = (payload: { displayConfirmModal(true) } -const onRemoveRequest = () => { +const onRemoveRequest = async () => { if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return const folderPath = editingFolderPath.value const requestIndex = editingRequestIndex.value @@ -1825,7 +1873,7 @@ const removeResponse = (payload: ResponseConfigPayload) => { displayConfirmModal(true) } -const onRemoveResponse = () => { +const onRemoveResponse = async () => { const request = cloneDeep(editingRequest.value) if (!request) return @@ -1840,6 +1888,8 @@ const onRemoveResponse = () => { } if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return const folderPath = editingFolderPath.value const requestIndex = editingRequestIndex.value @@ -2141,7 +2191,7 @@ const pathToLastIndex = (path: string) => { * This function is called when the user drops the request inside a collection * @param payload Object that contains the folder path, request index and the destination collection index */ -const dropRequest = (payload: { +const dropRequest = async (payload: { folderPath?: string | undefined requestIndex: string destinationCollectionIndex: string @@ -2153,6 +2203,8 @@ const dropRequest = (payload: { let possibleTab = null if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return possibleTab = tabs.getTabRefWithSaveContext({ originLocation: "user-collection", folderPath, @@ -2293,7 +2345,7 @@ const isMoveToSameLocation = ( * to a different collection or folder * @param payload - object containing the collection index dragged and the destination collection index */ -const dropCollection = (payload: { +const dropCollection = async (payload: { collectionIndexDragged: string destinationCollectionIndex: string destinationParentPath?: string @@ -2309,6 +2361,8 @@ const dropCollection = (payload: { if (collectionIndexDragged === destinationCollectionIndex) return if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return if ( checkIfCollectionIsAParentOfTheChildren( collectionIndexDragged, @@ -2430,11 +2484,13 @@ const isAlreadyInRoot = (id: string) => { * to the root * @param payload - object containing the collection index dragged */ -const dropToRoot = ({ dataTransfer }: DragEvent) => { +const dropToRoot = async ({ dataTransfer }: DragEvent) => { if (dataTransfer) { const collectionIndexDragged = dataTransfer.getData("collectionIndex") if (!collectionIndexDragged) return if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return // check if the collection is already in the root if (isAlreadyInRoot(collectionIndexDragged)) { toast.error(`${t("collection.invalid_root_move")}`) @@ -2545,7 +2601,7 @@ const isSameSameParent = ( * @param payload - object containing the request index dragged and the destination request index * with the destination collection index */ -const updateRequestOrder = (payload: { +const updateRequestOrder = async (payload: { dragedRequestIndex: string destinationRequestIndex: string | null destinationCollectionIndex: string @@ -2561,6 +2617,8 @@ const updateRequestOrder = (payload: { if (dragedRequestIndex === destinationRequestIndex) return if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return if ( !isSameSameParent( dragedRequestIndex, @@ -2616,7 +2674,7 @@ const updateRequestOrder = (payload: { * This function is called when the user updates the collection or folder order * @param payload - object containing the collection index dragged and the destination collection index */ -const updateCollectionOrder = (payload: { +const updateCollectionOrder = async (payload: { dragedCollectionIndex: string destinationCollection: { destinationCollectionIndex: string | null @@ -2630,6 +2688,8 @@ const updateCollectionOrder = (payload: { if (dragedCollectionIndex === destinationCollectionIndex) return if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return if ( !isSameSameParent( dragedCollectionIndex, @@ -2781,7 +2841,7 @@ const getCurrentValue = ( )?.currentValue } -const editProperties = (payload: { +const editProperties = async (payload: { collectionIndex: string collection: HoppCollection | TeamCollection }) => { @@ -2790,6 +2850,8 @@ const editProperties = (payload: { const collectionId = collection.id ?? collectionIndex.split("/").pop() if (collectionsType.value.type === "my-collections") { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return const parentIndex = collectionIndex.split("/").slice(0, -1).join("/") // remove last folder to get parent folder let inheritedProperties: HoppInheritedProperty = { diff --git a/packages/hoppscotch-common/src/components/environments/my/Environment.vue b/packages/hoppscotch-common/src/components/environments/my/Environment.vue index 32dcac7f..5cb94a7c 100644 --- a/packages/hoppscotch-common/src/components/environments/my/Environment.vue +++ b/packages/hoppscotch-common/src/components/environments/my/Environment.vue @@ -30,7 +30,7 @@ :icon="IconEdit" :title="`${t('action.edit')}`" class="hidden group-hover:inline-flex" - @click="emit('edit-environment')" + @click="emitEditEnvironment" /> @@ -150,6 +150,7 @@ import { deleteEnvironment, duplicateEnvironment, } from "~/newstore/environments" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" import { SecretEnvironmentService } from "~/services/secret-environment.service" import IconCopy from "~icons/lucide/copy" import IconEdit from "~icons/lucide/edit" @@ -160,6 +161,13 @@ import { CurrentValueService } from "~/services/current-environment-value.servic const t = useI18n() const toast = useToast() +const emitEditEnvironment = async (): Promise => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return false + emit("edit-environment") + return true +} + const props = withDefaults( defineProps<{ environment: Environment @@ -213,7 +221,9 @@ const duplicate = ref() const exportAsJsonEl = ref() const deleteAction = ref() -const removeEnvironment = () => { +const removeEnvironment = async () => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return if (props.environmentIndex === null) return if (!isGlobalEnvironment.value) { deleteEnvironment(props.environmentIndex as number, props.environment.id) @@ -223,7 +233,9 @@ const removeEnvironment = () => { toast.success(`${t("state.deleted")}`) } -const duplicateEnvironments = () => { +const duplicateEnvironments = async () => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return if (props.environmentIndex === null) { return } diff --git a/packages/hoppscotch-common/src/components/environments/my/index.vue b/packages/hoppscotch-common/src/components/environments/my/index.vue index e0f725f3..bc50d353 100644 --- a/packages/hoppscotch-common/src/components/environments/my/index.vue +++ b/packages/hoppscotch-common/src/components/environments/my/index.vue @@ -120,6 +120,7 @@ import { defineActionHandler } from "~/helpers/actions" import { sortPersonalEnvironmentsAlphabetically } from "~/helpers/utils/sortEnvironmentsAlphabetically" import { HandleEnvChangeProp } from "../index.vue" import { Environment } from "@hoppscotch/data" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" const t = useI18n() const colorMode = useColorMode() @@ -159,17 +160,25 @@ const editingEnvironmentIndex = ref(null) const editingVariableName = ref("") const secretOptionSelected = ref(false) -const displayModalAdd = (shouldDisplay: boolean) => { +const displayModalAdd = async (shouldDisplay: boolean) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return action.value = "new" showModalDetails.value = shouldDisplay } -const displayModalEdit = (shouldDisplay: boolean) => { - action.value = "edit" +const displayModalEdit = async (shouldDisplay: boolean) => { + if (shouldDisplay) { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + action.value = "edit" + } showModalDetails.value = shouldDisplay if (!shouldDisplay) resetSelectedData() } -const displayModalImportExport = (shouldDisplay: boolean) => { +const displayModalImportExport = async (shouldDisplay: boolean) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return showModalImportExport.value = shouldDisplay } const selectEnvironment = (index: number, environment: Environment) => { diff --git a/packages/hoppscotch-common/src/components/environments/teams/index.vue b/packages/hoppscotch-common/src/components/environments/teams/index.vue index 2c1c4add..2f3ab3d6 100644 --- a/packages/hoppscotch-common/src/components/environments/teams/index.vue +++ b/packages/hoppscotch-common/src/components/environments/teams/index.vue @@ -172,6 +172,7 @@ import { getEnvActionErrorMessage } from "~/helpers/error-messages" import { HandleEnvChangeProp } from "../index.vue" import { selectedEnvironmentIndex$ } from "~/newstore/environments" import { useReadonlyStream } from "~/composables/stream" +import { handleTokenValidation } from "~/helpers/handleTokenValidation" const t = useI18n() @@ -223,7 +224,9 @@ const selectedEnvironmentID = ref(null) const isTeamViewer = computed(() => props.team?.role === "VIEWER") -const displayModalAdd = (shouldDisplay: boolean) => { +const displayModalAdd = async (shouldDisplay: boolean) => { + const isValidToken = await handleTokenValidation() + if (!isValidToken) return action.value = "new" showModalDetails.value = shouldDisplay } diff --git a/packages/hoppscotch-common/src/helpers/handleTokenValidation.ts b/packages/hoppscotch-common/src/helpers/handleTokenValidation.ts new file mode 100644 index 00000000..04927bc9 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/handleTokenValidation.ts @@ -0,0 +1,19 @@ +import { useToast } from "@composables/toast" +import { isValidUser } from "~/helpers/isValidUser" + +/** + * High-level authentication validation handler with automatic error notifications. + * + * This wrapper around `isValidUser()` provides automatic toast error messages for invalid tokens. + * Use this when you want standard error handling with user notifications. + * + * For silent validation or custom error handling, use `isValidUser()` directly. + * + * @returns {Promise} True if user is valid, false otherwise (with toast error shown) + */ +export const handleTokenValidation = async (): Promise => { + const toast = useToast() + const { valid, error } = await isValidUser() + if (!valid) toast.error(error) + return valid +} diff --git a/packages/hoppscotch-common/src/helpers/isValidUser.ts b/packages/hoppscotch-common/src/helpers/isValidUser.ts new file mode 100644 index 00000000..3289133d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/isValidUser.ts @@ -0,0 +1,44 @@ +import { platform } from "~/platform" + +export type ValidUserResponse = { + valid: boolean + error: string +} + +export const SESSION_EXPIRED = "Session expired. Please log in again." + +/** + * Validates user authentication and token validity by making an API call. + * + * This function is kept separate from `handleTokenValidation()` to enable different use cases: + * - Silent validation for conditional UI states (e.g., disabling components on token expiration) + * - Background checks without triggering user notifications + * - Custom error handling based on validation results + * + * Use `handleTokenValidation()` when you need automatic toast error notifications. + * Use `isValidUser()` for silent validation or custom error handling scenarios. + * + * @returns {Promise} Authentication status with user existence and token validity + */ +export const isValidUser = async (): Promise => { + const user = platform.auth.getCurrentUser() + + if (user) { + try { + // If the platform provides a method to verify auth tokens, use it else assume tokens are valid (for central instance where firebase handles it) + const hasValidTokens = platform.auth.verifyAuthTokens + ? await platform.auth.verifyAuthTokens() + : true + + return { + valid: hasValidTokens, + error: hasValidTokens ? "" : SESSION_EXPIRED, + } + } catch (error) { + return { valid: false, error: SESSION_EXPIRED } + } + } + + // allow user to perform actions without being logged in + return { valid: true, error: "" } +} diff --git a/packages/hoppscotch-common/src/platform/auth.ts b/packages/hoppscotch-common/src/platform/auth.ts index 2bf308b0..231c6e2c 100644 --- a/packages/hoppscotch-common/src/platform/auth.ts +++ b/packages/hoppscotch-common/src/platform/auth.ts @@ -275,4 +275,10 @@ export type AuthPlatformDef = { * If a value is not given, then the value is assumed to be false. */ isEmailEditable?: boolean + + /** Verifies if the current user's authentication tokens are valid + * For self-hosted, this should verify the tokens with the backend + * @returns True if tokens are valid, false otherwise + */ + verifyAuthTokens?: () => Promise } diff --git a/packages/hoppscotch-selfhost-desktop/src/platform/auth.ts b/packages/hoppscotch-selfhost-desktop/src/platform/auth.ts index c317dfc0..b76d0381 100644 --- a/packages/hoppscotch-selfhost-desktop/src/platform/auth.ts +++ b/packages/hoppscotch-selfhost-desktop/src/platform/auth.ts @@ -394,4 +394,31 @@ export const def: AuthPlatformDef = { event: "logout", }) }, + + /** + * Verifies if the current user's authentication tokens are valid + * @returns True if tokens are valid, false otherwise + */ + async verifyAuthTokens() { + try { + const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL + + const client = await getClient() + + const response = await client.get( + `${BACKEND_API_URL}/auth/verify-token`, + { + headers: { + "Content-Type": "application/json", + ...this.getBackendHeaders(), + }, + } + ) + + // axios automatically throws on error status codes, so if we reach here, it was successful + return !!response.data.isValid + } catch (error) { + return false + } + }, } diff --git a/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts index 25e1f0fe..5315ae5c 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/auth/desktop/index.ts @@ -526,4 +526,30 @@ export const def: AuthPlatformDef = { event: "logout", }) }, + + /** + * Verifies if the current user's authentication tokens are valid + * @returns True if tokens are valid, false otherwise + */ + async verifyAuthTokens() { + const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL + + const { response } = interceptorService.execute({ + id: Date.now(), + url: `${BACKEND_API_URL}/auth/verify-token`, + method: "GET", + version: "HTTP/1.1", + headers: { + "Content-Type": "application/json", + ...this.getBackendHeaders(), + }, + }) + + const res = await response + if (E.isLeft(res)) { + return false + } + + return res.right.isValid + }, } diff --git a/packages/hoppscotch-selfhost-web/src/platform/auth/web/index.ts b/packages/hoppscotch-selfhost-web/src/platform/auth/web/index.ts index b325779e..a3174f58 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/auth/web/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/auth/web/index.ts @@ -371,4 +371,27 @@ export const def: AuthPlatformDef = { } }, getAllowedAuthProviders, + + /** + * Verifies if the current user's authentication tokens are valid + * @returns True if tokens are valid, false otherwise + */ + async verifyAuthTokens() { + try { + const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL + + const response = await axios.get(`${BACKEND_API_URL}/auth/verify-token`, { + headers: { + "Content-Type": "application/json", + ...this.getBackendHeaders(), + }, + withCredentials: true, + }) + + // axios automatically throws on error status codes, so if we reach here, it was successful + return !!response.data.isValid + } catch (error) { + return false + } + }, }