diff --git a/src/Common/hooks/useAuthUser.ts b/src/Common/hooks/useAuthUser.ts index e46bbb4f0b2..dda5f399952 100644 --- a/src/Common/hooks/useAuthUser.ts +++ b/src/Common/hooks/useAuthUser.ts @@ -1,14 +1,32 @@ import { createContext, useContext } from "react"; import { UserModel } from "../../Components/Users/models"; +import { RequestResult } from "../../Utils/request/types"; +import { JwtTokenObtainPair, LoginCredentials } from "../../Redux/api"; -export const AuthUserContext = createContext(null); +type SignInReturnType = RequestResult; -export default function useAuthUser() { - const user = useContext(AuthUserContext); +type AuthContextType = { + user: UserModel | undefined; + signIn: (creds: LoginCredentials) => Promise; + signOut: () => Promise; +}; - if (!user) { - throw new Error("useAuthUser must be used within an AuthUserProvider"); +export const AuthUserContext = createContext(null); + +export const useAuthContext = () => { + const ctx = useContext(AuthUserContext); + if (!ctx) { + throw new Error( + "'useAuthContext' must be used within 'AuthUserProvider' only" + ); } + return ctx; +}; +export default function useAuthUser() { + const user = useAuthContext().user; + if (!user) { + throw new Error("'useAuthUser' must be used within 'AppRouter' only"); + } return user; } diff --git a/src/Components/Auth/Login.tsx b/src/Components/Auth/Login.tsx index 4aad207c25a..c2acb88cf0c 100644 --- a/src/Components/Auth/Login.tsx +++ b/src/Components/Auth/Login.tsx @@ -9,12 +9,13 @@ import LanguageSelectorLogin from "../Common/LanguageSelectorLogin"; import CareIcon from "../../CAREUI/icons/CareIcon"; import useConfig from "../../Common/hooks/useConfig"; import CircularProgress from "../Common/components/CircularProgress"; -import { LocalStorageKeys } from "../../Common/constants"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; -import { handleRedirection, invalidateFiltersCache } from "../../Utils/utils"; +import { invalidateFiltersCache } from "../../Utils/utils"; +import { useAuthContext } from "../../Common/hooks/useAuthUser"; export const Login = (props: { forgot?: boolean }) => { + const { signIn } = useAuthContext(); const { main_logo, recaptcha_site_key, @@ -82,7 +83,7 @@ export const Login = (props: { forgot?: boolean }) => { return form; }; - // set loading to false when component is dismounted + // set loading to false when component is unmounted useEffect(() => { return () => { setLoading(false); @@ -91,35 +92,17 @@ export const Login = (props: { forgot?: boolean }) => { const handleSubmit = async (e: any) => { e.preventDefault(); + + setLoading(true); invalidateFiltersCache(); - const valid = validateData(); - if (valid) { - // replaces button with spinner - setLoading(true); - const { res, data } = await request(routes.login, { - body: { ...valid }, - }); - if (res && res.status === 429) { - setCaptcha(true); - // captcha displayed set back to login button - setLoading(false); - } else if (res && res.status === 200 && data) { - localStorage.setItem(LocalStorageKeys.accessToken, data.access); - localStorage.setItem(LocalStorageKeys.refreshToken, data.refresh); - if ( - window.location.pathname === "/" || - window.location.pathname === "/login" - ) { - handleRedirection(); - } else { - window.location.href = window.location.pathname.toString(); - } - } else { - // error from server set back to login button - setLoading(false); - } - } + const validated = validateData(); + if (!validated) return; + + const { res } = await signIn(validated); + + setCaptcha(res?.status === 429); + setLoading(false); }; const validateForgetData = () => { diff --git a/src/Components/Common/Sidebar/SidebarUserCard.tsx b/src/Components/Common/Sidebar/SidebarUserCard.tsx index 59970e8a73c..75cf2d9ce43 100644 --- a/src/Components/Common/Sidebar/SidebarUserCard.tsx +++ b/src/Components/Common/Sidebar/SidebarUserCard.tsx @@ -1,13 +1,13 @@ import { Link } from "raviger"; import { useTranslation } from "react-i18next"; import CareIcon from "../../../CAREUI/icons/CareIcon"; -import { handleSignOut } from "../../../Utils/utils"; -import useAuthUser from "../../../Common/hooks/useAuthUser"; +import { formatName } from "../../../Utils/utils"; +import useAuthUser, { useAuthContext } from "../../../Common/hooks/useAuthUser"; const SidebarUserCard = ({ shrinked }: { shrinked: boolean }) => { const { t } = useTranslation(); const user = useAuthUser(); - const profileName = `${user.first_name ?? ""} ${user.last_name ?? ""}`.trim(); + const { signOut } = useAuthContext(); return (
{ -
handleSignOut(true)} - > +
{ className="flex-nowrap overflow-hidden break-words font-semibold text-white" id="profilenamelink" > - {profileName} + {formatName(user)}
handleSignOut(true)} + onClick={signOut} >
{ - handleSignOut(false); - }} + onClick={signOut} className="hover:bg-primary- inline-block cursor-pointer rounded-lg bg-primary-600 px-4 py-2 text-white hover:text-white" > {t("return_to_login")} diff --git a/src/Components/Users/UserProfile.tsx b/src/Components/Users/UserProfile.tsx index 76a94745c1a..2dc75d33613 100644 --- a/src/Components/Users/UserProfile.tsx +++ b/src/Components/Users/UserProfile.tsx @@ -5,7 +5,7 @@ import * as Notification from "../../Utils/Notifications.js"; import LanguageSelector from "../../Components/Common/LanguageSelector"; import TextFormField from "../Form/FormFields/TextFormField"; import ButtonV2, { Submit } from "../Common/components/ButtonV2"; -import { classNames, handleSignOut, parsePhoneNumber } from "../../Utils/utils"; +import { classNames, parsePhoneNumber } from "../../Utils/utils"; import CareIcon from "../../CAREUI/icons/CareIcon"; import PhoneNumberFormField from "../Form/FormFields/PhoneNumberFormField"; import { FieldChangeEvent } from "../Form/FormFields/Utils"; @@ -13,7 +13,7 @@ import { SelectFormField } from "../Form/FormFields/SelectFormField"; import { GenderType, SkillModel, UpdatePasswordForm } from "../Users/models"; import UpdatableApp, { checkForUpdate } from "../Common/UpdatableApp"; import dayjs from "../../Utils/dayjs"; -import useAuthUser from "../../Common/hooks/useAuthUser"; +import useAuthUser, { useAuthContext } from "../../Common/hooks/useAuthUser"; import { PhoneNumberValidator } from "../Form/FieldValidators"; import useQuery from "../../Utils/request/useQuery"; import routes from "../../Redux/api"; @@ -100,6 +100,7 @@ const editFormReducer = (state: State, action: Action) => { }; export default function UserProfile() { + const { signOut } = useAuthContext(); const [states, dispatch] = useReducer(editFormReducer, initialState); const [updateStatus, setUpdateStatus] = useState({ isChecking: false, @@ -413,7 +414,7 @@ export default function UserProfile() { > {showEdit ? "Cancel" : "Edit User Profile"} - handleSignOut(true)}> + Sign out diff --git a/src/Providers/AuthUserProvider.tsx b/src/Providers/AuthUserProvider.tsx index d515149341e..5435b0f0b45 100644 --- a/src/Providers/AuthUserProvider.tsx +++ b/src/Providers/AuthUserProvider.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { AuthUserContext } from "../Common/hooks/useAuthUser"; import Loading from "../Components/Common/Loading"; import routes from "../Redux/api"; @@ -6,6 +6,7 @@ import useQuery from "../Utils/request/useQuery"; import { LocalStorageKeys } from "../Common/constants"; import request from "../Utils/request/request"; import useConfig from "../Common/hooks/useConfig"; +import { navigate } from "raviger"; interface Props { children: React.ReactNode; @@ -14,34 +15,78 @@ interface Props { export default function AuthUserProvider({ children, unauthorized }: Props) { const { jwt_token_refresh_interval } = useConfig(); - const { res, data, loading } = useQuery(routes.currentUser, { - refetchOnWindowFocus: false, - prefetch: true, - silent: true, - }); + const tokenRefreshInterval = jwt_token_refresh_interval ?? 5 * 60 * 1000; + + const { + res, + data: user, + loading, + refetch, + } = useQuery(routes.currentUser, { silent: true }); useEffect(() => { - if (!data) { + if (!user) { return; } updateRefreshToken(true); - setInterval( - () => updateRefreshToken(), - jwt_token_refresh_interval ?? 5 * 60 * 1000 - ); - }, [data, jwt_token_refresh_interval]); + setInterval(() => updateRefreshToken(), tokenRefreshInterval); + }, [user, tokenRefreshInterval]); + + const signIn = useCallback( + async (creds: { username: string; password: string }) => { + const query = await request(routes.login, { body: creds }); + + if (query.res?.ok && query.data) { + localStorage.setItem(LocalStorageKeys.accessToken, query.data.access); + localStorage.setItem(LocalStorageKeys.refreshToken, query.data.refresh); + + await refetch(); + navigate(getRedirectOr("/")); + } + + return query; + }, + [refetch] + ); + + const signOut = useCallback(async () => { + localStorage.removeItem(LocalStorageKeys.accessToken); + localStorage.removeItem(LocalStorageKeys.refreshToken); + + await refetch(); + + const redirectURL = getRedirectURL(); + navigate(redirectURL ? `/?redirect=${redirectURL}` : "/"); + }, [refetch]); + + // Handles signout from current tab, if signed out from another tab. + useEffect(() => { + const listener = (event: any) => { + if ( + !event.newValue && + (LocalStorageKeys.accessToken === event.key || + LocalStorageKeys.refreshToken === event.key) + ) { + signOut(); + } + }; + + addEventListener("storage", listener); + + return () => { + removeEventListener("storage", listener); + }; + }, [signOut]); if (loading || !res) { return ; } - if (res.status !== 200 || !data) { - return unauthorized; - } - return ( - {children} + + {!res.ok || !user ? unauthorized : children} + ); } @@ -66,3 +111,25 @@ const updateRefreshToken = async (silent = false) => { localStorage.setItem(LocalStorageKeys.accessToken, data.access); localStorage.setItem(LocalStorageKeys.refreshToken, data.refresh); }; + +const getRedirectURL = () => { + return new URLSearchParams(window.location.search).get("redirect"); +}; + +const getRedirectOr = (fallback: string) => { + const url = getRedirectURL(); + + if (url) { + try { + const redirect = new URL(url); + if (window.location.origin === redirect.origin) { + return redirect.pathname + redirect.search; + } + console.error("Redirect does not belong to same origin."); + } catch { + console.error(`Invalid redirect URL: ${url}`); + } + } + + return fallback; +}; diff --git a/src/Redux/api.tsx b/src/Redux/api.tsx index 2a7681975a4..31284d60ecf 100644 --- a/src/Redux/api.tsx +++ b/src/Redux/api.tsx @@ -80,12 +80,12 @@ export function Type(): T { return {} as T; } -interface JwtTokenObtainPair { +export interface JwtTokenObtainPair { access: string; refresh: string; } -interface LoginInput { +export interface LoginCredentials { username: string; password: string; } @@ -104,16 +104,14 @@ const routes = { method: "POST", noAuth: true, TRes: Type(), - TBody: Type(), + TBody: Type(), }, token_refresh: { path: "/api/v1/auth/token/refresh/", method: "POST", TRes: Type(), - TBody: Type<{ - refresh: string; - }>(), + TBody: Type<{ refresh: JwtTokenObtainPair["refresh"] }>(), }, token_verify: { diff --git a/src/Routers/AppRouter.tsx b/src/Routers/AppRouter.tsx index d098a480149..5e238627bc8 100644 --- a/src/Routers/AppRouter.tsx +++ b/src/Routers/AppRouter.tsx @@ -10,9 +10,8 @@ import { SIDEBAR_SHRINK_PREFERENCE_KEY, SidebarShrinkContext, } from "../Components/Common/Sidebar/Sidebar"; -import { BLACKLISTED_PATHS, LocalStorageKeys } from "../Common/constants"; +import { BLACKLISTED_PATHS } from "../Common/constants"; import useConfig from "../Common/hooks/useConfig"; -import { handleSignOut } from "../Utils/utils"; import SessionExpired from "../Components/ErrorPages/SessionExpired"; import UserRoutes from "./routes/UserRoutes"; @@ -63,19 +62,6 @@ export default function AppRouter() { const path = usePath(); const [sidebarOpen, setSidebarOpen] = useState(false); - useEffect(() => { - addEventListener("storage", (event: any) => { - if ( - [LocalStorageKeys.accessToken, LocalStorageKeys.refreshToken].includes( - event.key - ) && - !event.newValue - ) { - handleSignOut(true); - } - }); - }, []); - useEffect(() => { setSidebarOpen(false); let flag = false; diff --git a/src/Utils/request/utils.ts b/src/Utils/request/utils.ts index f22dca369f2..dd1f79fce4f 100644 --- a/src/Utils/request/utils.ts +++ b/src/Utils/request/utils.ts @@ -92,6 +92,6 @@ export function mergeRequestOptions( options.onResponse?.(res); overrides.onResponse?.(res); }, - silent: overrides.silent || options.silent, + silent: overrides.silent ?? options.silent, }; } diff --git a/src/Utils/utils.ts b/src/Utils/utils.ts index c3e2030b3ed..5f763c9c445 100644 --- a/src/Utils/utils.ts +++ b/src/Utils/utils.ts @@ -1,8 +1,6 @@ -import { navigate } from "raviger"; import { AREACODES, IN_LANDLINE_AREA_CODES, - LocalStorageKeys, USER_TYPES, } from "../Common/constants"; import phoneCodesJson from "../Common/static/countryPhoneAndFlags.json"; @@ -121,41 +119,6 @@ export const dateQueryString = (date: DateLike) => { export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -export const handleSignOut = (forceReload: boolean) => { - Object.values(LocalStorageKeys).forEach((key) => - localStorage.removeItem(key) - ); - const redirectURL = new URLSearchParams(window.location.search).get( - "redirect" - ); - const url = redirectURL ? `/?redirect=${redirectURL}` : "/"; - if (forceReload) { - window.location.href = url; - } else { - navigate(url); - } -}; - -export const handleRedirection = () => { - const redirectParam = new URLSearchParams(window.location.search).get( - "redirect" - ); - try { - if (redirectParam) { - const redirectURL = new URL(redirectParam); - - if (redirectURL.origin === window.location.origin) { - const newPath = redirectURL.pathname + redirectURL.search; - window.location.href = `${window.location.origin}${newPath}`; - return; - } - } - window.location.href = "/facility"; - } catch { - window.location.href = "/facility"; - } -}; - /** * Referred from: https://stackoverflow.com/a/9039885/7887936 * @returns `true` if device is iOS, else `false`