fix: blank screen in admin dashboard on authentication problems (#3385)

* fix: dashboard logs out user when cookie expires or is unauthorized

* fix: handles the 401 error thrown when trying to refresh tokens

* chore: updated wrong logic when returning state in refresh token function

* feat: introduced auth exchange to urql client to check for errors on each backend call

* fix: prevent multiple window reloads

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
This commit is contained in:
Joel Jacob Stephen
2023-10-09 10:08:35 +05:30
committed by GitHub
parent 0188a8d7db
commit b18fd90b64
5 changed files with 2924 additions and 548 deletions

View File

@@ -22,7 +22,8 @@
"@intlify/unplugin-vue-i18n": "^1.2.0", "@intlify/unplugin-vue-i18n": "^1.2.0",
"@types/cors": "^2.8.13", "@types/cors": "^2.8.13",
"@types/express": "^4.17.15", "@types/express": "^4.17.15",
"@urql/vue": "^1.0.4", "@urql/exchange-auth": "^2.1.6",
"@urql/vue": "^1.1.2",
"@vueuse/core": "^9.10.0", "@vueuse/core": "^9.10.0",
"axios": "^0.21.4", "axios": "^0.21.4",
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@@ -1,42 +1,39 @@
// generated by unplugin-vue-components // generated by unplugin-vue-components
// We suggest you to commit this file into source control // We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core' import '@vue/runtime-core';
export {} export {};
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AppHeader: typeof import('./components/app/Header.vue')['default'] AppHeader: typeof import('./components/app/Header.vue')['default'];
AppLogin: typeof import('./components/app/Login.vue')['default'] AppLogin: typeof import('./components/app/Login.vue')['default'];
AppLogout: typeof import('./components/app/Logout.vue')['default'] AppLogout: typeof import('./components/app/Logout.vue')['default'];
AppModal: typeof import('./components/app/Modal.vue')['default'] AppModal: typeof import('./components/app/Modal.vue')['default'];
AppSidebar: typeof import('./components/app/Sidebar.vue')['default'] AppSidebar: typeof import('./components/app/Sidebar.vue')['default'];
AppToast: typeof import('./components/app/Toast.vue')['default'] AppToast: typeof import('./components/app/Toast.vue')['default'];
DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'] DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'];
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'] HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'];
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'] HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'];
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'] HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'];
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete'] HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete'];
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'];
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'] HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'];
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'];
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'] HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'];
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'] HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'];
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'];
HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable'] IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'];
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] IconLucideInbox: typeof import('~icons/lucide/inbox')['default'];
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] TeamsAdd: typeof import('./components/teams/Add.vue')['default'];
IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] TeamsDetails: typeof import('./components/teams/Details.vue')['default'];
TeamsAdd: typeof import('./components/teams/Add.vue')['default'] TeamsInvite: typeof import('./components/teams/Invite.vue')['default'];
TeamsDetails: typeof import('./components/teams/Details.vue')['default'] TeamsMembers: typeof import('./components/teams/Members.vue')['default'];
TeamsInvite: typeof import('./components/teams/Invite.vue')['default'] TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default'];
TeamsMembers: typeof import('./components/teams/Members.vue')['default'] TeamsTable: typeof import('./components/teams/Table.vue')['default'];
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default'] Tippy: typeof import('vue-tippy')['Tippy'];
TeamsTable: typeof import('./components/teams/Table.vue')['default'] UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default'];
Tippy: typeof import('vue-tippy')['Tippy'] UsersTable: typeof import('./components/users/Table.vue')['default'];
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default']
UsersTable: typeof import('./components/users/Table.vue')['default']
} }
} }

View File

@@ -6,7 +6,7 @@ import {
setLocalConfig, setLocalConfig,
} from './localpersistence'; } from './localpersistence';
import { Ref, ref, watch } from 'vue'; import { Ref, ref, watch } from 'vue';
import * as O from 'fp-ts/Option';
/** /**
* A common (and required) set of fields that describe a user. * A common (and required) set of fields that describe a user.
*/ */
@@ -60,6 +60,24 @@ async function logout() {
}); });
} }
const signOut = async (reloadWindow = false) => {
await logout();
// Reload the window if both `access_token` and `refresh_token`is invalid
// there by the user is taken to the login page
if (reloadWindow) {
window.location.reload();
}
probableUser$.next(null);
currentUser$.next(null);
removeLocalConfig('login_state');
authEvents$.next({
event: 'logout',
});
};
async function signInUserWithGithubFB() { async function signInUserWithGithubFB() {
window.location.href = `${ window.location.href = `${
import.meta.env.VITE_BACKEND_API_URL import.meta.env.VITE_BACKEND_API_URL
@@ -149,6 +167,7 @@ async function setInitialUser() {
setInitialUser(); setInitialUser();
} else { } else {
setUser(null); setUser(null);
await signOut(true);
isGettingInitialUser.value = false; isGettingInitialUser.value = false;
} }
@@ -187,24 +206,22 @@ async function setInitialUser() {
} }
} }
async function refreshToken() { const refreshToken = async () => {
const res = await axios.get( try {
`${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`, const res = await axios.get(
{ `${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`,
withCredentials: true, {
} withCredentials: true,
); }
);
const isSuccessful = res.status === 200;
if (isSuccessful) {
authEvents$.next({ authEvents$.next({
event: 'token_refresh', event: 'token_refresh',
}); });
return res.status === 200;
} catch {
return false;
} }
};
return isSuccessful;
}
async function elevateUser() { async function elevateUser() {
const res = await axios.get( const res = await axios.get(
@@ -356,18 +373,21 @@ export const auth = {
return; return;
}, },
async signOutUser() { async performAuthRefresh() {
// if (!currentUser$.value) throw new Error("No user has logged in") const isRefreshSuccess = await refreshToken();
await logout(); if (isRefreshSuccess) {
setInitialUser();
return O.some(true);
} else {
setUser(null);
isGettingInitialUser.value = false;
return O.none;
}
},
probableUser$.next(null); async signOutUser(reloadWindow = false) {
currentUser$.next(null); await signOut(reloadWindow);
removeLocalConfig('login_state');
authEvents$.next({
event: 'logout',
});
}, },
async processMagicLink() { async processMagicLink() {

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import urql, { createClient } from '@urql/vue'; import urql, { createClient, cacheExchange, fetchExchange } from '@urql/vue';
import { authExchange } from '@urql/exchange-auth';
import App from './App.vue'; import App from './App.vue';
// STYLES // STYLES
@@ -11,9 +12,10 @@ import '@fontsource-variable/inter';
import '@fontsource-variable/material-symbols-rounded'; import '@fontsource-variable/material-symbols-rounded';
import '@fontsource-variable/roboto-mono'; import '@fontsource-variable/roboto-mono';
// END STYLES // END STYLES
import { HOPP_MODULES } from './modules'; import { HOPP_MODULES } from './modules';
import { auth } from './helpers/auth'; import { auth } from './helpers/auth';
import { pipe } from 'fp-ts/function';
import * as O from 'fp-ts/Option';
// Top-level await is not available in our targets // Top-level await is not available in our targets
(async () => { (async () => {
@@ -27,6 +29,28 @@ import { auth } from './helpers/auth';
credentials: 'include', credentials: 'include',
}; };
}, },
exchanges: [
cacheExchange,
authExchange(async () => {
return {
addAuthToOperation(operation) {
return operation;
},
async refreshAuth() {
pipe(
await auth.performAuthRefresh(),
O.getOrElseW(async () => await auth.signOutUser(true))
);
},
didAuthError(error, _operation) {
return error.message === '[GraphQL] Unauthorized';
},
};
}),
fetchExchange,
],
}) })
); );

3308
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff