diff --git a/app/src/components/explorer/Pagination.vue b/app/src/components/explorer/Pagination.vue index b3b76874a..efcb9f847 100644 --- a/app/src/components/explorer/Pagination.vue +++ b/app/src/components/explorer/Pagination.vue @@ -142,6 +142,7 @@ const prevRow = (): void => { watch(() => props.cpage, (newValue, oldValue) => { if ((newValue !== oldValue) && pageExists(newValue)) { pageInput.value = newValue; + verifyRow(); } }); diff --git a/app/src/composables/useXmlViewer.ts b/app/src/composables/useXmlViewer.ts new file mode 100644 index 000000000..d0a45e673 --- /dev/null +++ b/app/src/composables/useXmlViewer.ts @@ -0,0 +1,63 @@ +import { ref, computed, watch } from 'vue'; +import { useRoute } from 'vue-router'; +import { useStore } from 'vuex'; +import { useQuery } from '@vue/apollo-composable'; +import { XML_VIEWER } from '@/modules/gql/xml-gql'; + +export function useXmlViewer() { + const route = useRoute(); + const store = useStore(); + + const xmlViewer = ref({}); + + const { result, loading, refetch } = useQuery( + XML_VIEWER, + computed(() => ({ + input: { + id: route.params.id, + isNewCuration: route.query?.isNewCuration + ? JSON.parse(route.query.isNewCuration as string) + : false, + }, + })), + { fetchPolicy: 'cache-and-network' } + ); + + watch( + result, + (data) => { + if (data) { + xmlViewer.value = data.xmlViewer; + } + }, + { immediate: true } + ); + + const isAuth = computed(() => store.getters['auth/isAuthenticated']); + const isAdmin = computed(() => store.getters['auth/isAdmin']); + const dialogBoxActive = computed(() => store.getters.dialogBox); + + const closeDialogBox = () => { + store.commit('setDialogBox'); + }; + + const approveCuration = () => { + store.commit('setDialogBox'); + }; + + const approval = async () => { + await store.dispatch('explorer/curation/approveCuration', { xml: xmlViewer.value }); + }; + + return { + xmlViewer, + loading, + refetch, + isAuth, + isAdmin, + dialogBoxActive, + closeDialogBox, + approveCuration, + approval, + }; +} diff --git a/app/src/modules/auth-utils.ts b/app/src/modules/auth-utils.ts new file mode 100644 index 000000000..81ccdf00b --- /dev/null +++ b/app/src/modules/auth-utils.ts @@ -0,0 +1,40 @@ +/** + * JWT auth utilities for frontend token validation and cleanup. + * Decodes JWT payload via base64 (no secret needed) to check expiry. + */ + +export const AUTH_STORAGE_KEYS = [ + 'token', + 'userId', + 'displayName', + 'surName', + 'givenName', + 'isAdmin', + 'tokenExpiration', +]; + +export function clearAuthStorage (): void { + AUTH_STORAGE_KEYS.forEach((key) => localStorage.removeItem(key)); +} + +export function getTokenExp (token: string): number | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const payload = JSON.parse(atob(parts[1])); + return typeof payload.exp === 'number' ? payload.exp : null; + } catch { + return null; + } +} + +export function isTokenExpired (token: string): boolean { + const exp = getTokenExp(token); + if (exp === null) return true; + return Date.now() >= exp * 1000; +} + +export function forceLogout (): void { + clearAuthStorage(); + window.location.href = '/nm'; +} diff --git a/app/src/modules/gql/apolloClient.ts b/app/src/modules/gql/apolloClient.ts index 5e29c3503..6080d4bf5 100644 --- a/app/src/modules/gql/apolloClient.ts +++ b/app/src/modules/gql/apolloClient.ts @@ -1,12 +1,29 @@ -import { ApolloClient, ApolloLink, InMemoryCache, HttpLink } from '@apollo/client/core'; +import { + ApolloClient, + ApolloLink, + InMemoryCache, + HttpLink, + Observable, + from, +} from '@apollo/client/core'; +import { onError } from '@apollo/client/link/error'; +import { isTokenExpired, forceLogout } from '../auth-utils'; const BASE = window.location.origin; const uri = `${BASE}/api/graphql`; const httpLink = new HttpLink({ uri }); const authLink = new ApolloLink((operation, forward) => { - // add the authorization to the headers const token = localStorage.getItem('token') || null; + + if (token && isTokenExpired(token)) { + forceLogout(); + return new Observable((observer) => { + observer.error(new Error('Token expired')); + observer.complete(); + }); + } + operation.setContext({ headers: { authorization: token ? `${token}` : '', @@ -15,8 +32,29 @@ const authLink = new ApolloLink((operation, forward) => { return forward(operation); }); +const errorLink = onError(({ graphQLErrors, networkError }) => { + const authMessages = ['not authenticated', 'jwt expired', 'invalid token', 'jwt malformed']; + + if (graphQLErrors) { + for (const err of graphQLErrors) { + const msg = (err.message || '').toLowerCase(); + if (authMessages.some((keyword) => msg.includes(keyword))) { + forceLogout(); + return; + } + } + } + + if (networkError && 'statusCode' in networkError) { + const status = (networkError as any).statusCode; + if (status === 401 || status === 403) { + forceLogout(); + } + } +}); + export default new ApolloClient({ uri: uri, - link: authLink.concat(httpLink), // Chain auth token with the HttpLink + link: from([errorLink, authLink, httpLink]), cache: new InMemoryCache(), }); diff --git a/app/src/pages/explorer/xml/Xml.vue b/app/src/pages/explorer/xml/Xml.vue index db2bb92a0..7dfa5ad1d 100644 --- a/app/src/pages/explorer/xml/Xml.vue +++ b/app/src/pages/explorer/xml/Xml.vue @@ -179,9 +179,9 @@ @@ -194,7 +194,7 @@ diff --git a/app/src/pages/explorer/xml/XmlHistory.vue b/app/src/pages/explorer/xml/XmlHistory.vue index 285599f9e..550c63819 100644 --- a/app/src/pages/explorer/xml/XmlHistory.vue +++ b/app/src/pages/explorer/xml/XmlHistory.vue @@ -1,5 +1,24 @@