Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent CARE from reloading during sign in/out πŸƒβ€β™‚οΈ #6828

Merged
merged 6 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading