Skip to content

Commit

Permalink
Prevent CARE from reloading during sign in/out πŸƒβ€β™‚οΈ (#6828)
Browse files Browse the repository at this point in the history
* Improve sign in flow

* Improve sign out flow

* Apply suggestions from code review

* lints

* i should have fixed lints from vscode itself ig
  • Loading branch information
rithviknishad authored Dec 13, 2023
1 parent e292fb7 commit 27c5766
Show file tree
Hide file tree
Showing 10 changed files with 141 additions and 130 deletions.
28 changes: 23 additions & 5 deletions src/Common/hooks/useAuthUser.ts
Original file line number Diff line number Diff line change
@@ -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<UserModel | null>(null);
type SignInReturnType = RequestResult<JwtTokenObtainPair>;

export default function useAuthUser() {
const user = useContext(AuthUserContext);
type AuthContextType = {
user: UserModel | undefined;
signIn: (creds: LoginCredentials) => Promise<SignInReturnType>;
signOut: () => Promise<void>;
};

if (!user) {
throw new Error("useAuthUser must be used within an AuthUserProvider");
export const AuthUserContext = createContext<AuthContextType | null>(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;
}
43 changes: 13 additions & 30 deletions src/Components/Auth/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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 = () => {
Expand Down
15 changes: 6 additions & 9 deletions src/Components/Common/Sidebar/SidebarUserCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
Expand All @@ -18,10 +18,7 @@ const SidebarUserCard = ({ shrinked }: { shrinked: boolean }) => {
<Link href="/user/profile" className="flex-none py-3">
<CareIcon className="care-l-user-circle text-3xl text-white" />
</Link>
<div
className="flex cursor-pointer justify-center"
onClick={() => handleSignOut(true)}
>
<div className="flex cursor-pointer justify-center" onClick={signOut}>
<CareIcon
className={`care-l-sign-out-alt text-2xl text-gray-400 ${
shrinked ? "visible" : "hidden"
Expand All @@ -39,12 +36,12 @@ const SidebarUserCard = ({ shrinked }: { shrinked: boolean }) => {
className="flex-nowrap overflow-hidden break-words font-semibold text-white"
id="profilenamelink"
>
{profileName}
{formatName(user)}
</Link>
</div>
<div
className="min-h-6 flex cursor-pointer items-center"
onClick={() => handleSignOut(true)}
onClick={signOut}
>
<CareIcon
className={`care-l-sign-out-alt ${
Expand Down
12 changes: 5 additions & 7 deletions src/Components/ErrorPages/SessionExpired.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import * as Notification from "../../Utils/Notifications";
import { useNavigate } from "raviger";
import { useContext, useEffect } from "react";
import { handleSignOut } from "../../Utils/utils";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { AuthUserContext } from "../../Common/hooks/useAuthUser";
import { useAuthContext } from "../../Common/hooks/useAuthUser";

export default function SessionExpired() {
const isAuthenticated = !!useContext(AuthUserContext);
const { signOut, user } = useAuthContext();
const isAuthenticated = !!user;
const navigate = useNavigate();
const { t } = useTranslation();

Expand All @@ -32,9 +32,7 @@ export default function SessionExpired() {
<br />
<br />
<div
onClick={() => {
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")}
Expand Down
7 changes: 4 additions & 3 deletions src/Components/Users/UserProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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";
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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -413,7 +414,7 @@ export default function UserProfile() {
>
{showEdit ? "Cancel" : "Edit User Profile"}
</ButtonV2>
<ButtonV2 variant="danger" onClick={(_) => handleSignOut(true)}>
<ButtonV2 variant="danger" onClick={signOut}>
<CareIcon className="care-l-sign-out-alt" />
Sign out
</ButtonV2>
Expand Down
101 changes: 84 additions & 17 deletions src/Providers/AuthUserProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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";
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;
Expand All @@ -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 <Loading />;
}

if (res.status !== 200 || !data) {
return unauthorized;
}

return (
<AuthUserContext.Provider value={data}>{children}</AuthUserContext.Provider>
<AuthUserContext.Provider value={{ signIn, signOut, user }}>
{!res.ok || !user ? unauthorized : children}
</AuthUserContext.Provider>
);
}

Expand All @@ -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;
};
10 changes: 4 additions & 6 deletions src/Redux/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,12 @@ export function Type<T>(): T {
return {} as T;
}

interface JwtTokenObtainPair {
export interface JwtTokenObtainPair {
access: string;
refresh: string;
}

interface LoginInput {
export interface LoginCredentials {
username: string;
password: string;
}
Expand All @@ -104,16 +104,14 @@ const routes = {
method: "POST",
noAuth: true,
TRes: Type<JwtTokenObtainPair>(),
TBody: Type<LoginInput>(),
TBody: Type<LoginCredentials>(),
},

token_refresh: {
path: "/api/v1/auth/token/refresh/",
method: "POST",
TRes: Type<JwtTokenObtainPair>(),
TBody: Type<{
refresh: string;
}>(),
TBody: Type<{ refresh: JwtTokenObtainPair["refresh"] }>(),
},

token_verify: {
Expand Down
Loading

0 comments on commit 27c5766

Please sign in to comment.