Skip to content

Commit

Permalink
feat: added optional OTP + /account page - fix #53
Browse files Browse the repository at this point in the history
  • Loading branch information
pagoru committed Sep 25, 2024
1 parent 84bae0a commit 97be0c6
Show file tree
Hide file tree
Showing 33 changed files with 854 additions and 78 deletions.
3 changes: 2 additions & 1 deletion app/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"react-router": "6.26.0",
"react-router-dom": "6.26.0",
"sass": "1.77.8",
"js-cookie": "3.0.5"
"js-cookie": "3.0.5",
"qrcode": "1.5.4"
},
"packageManager": "[email protected]"
}
91 changes: 91 additions & 0 deletions app/client/src/modules/account/account.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React, { FormEvent, useCallback, useEffect, useState } from "react";
import { useAccount, useApi, useQR } from "shared/hooks";
import { Navigate, useNavigate } from "react-router-dom";
import { LinkComponent } from "shared/components";

export const AccountComponent: React.FC = () => {
const [loaded, setLoaded] = useState<boolean>(false);
const [otpLoaded, setOTPLoaded] = useState<boolean>(false);

const [account, setAccount] = useState<{ username: string; email: string }>();
const [otpUrl, setOTPUrl] = useState<string>();
let navigate = useNavigate();

const { refreshSession } = useApi();
const { getAccount, getOTP, verifyOTP, deleteOTP } = useAccount();
const { getQR } = useQR();

const $reloadOTP = () => {
getOTP().then(async (uri) => {
if (uri) setOTPUrl(await getQR(uri));
setOTPLoaded(true);
});
};

useEffect(() => {
refreshSession()
.then(async () => {
setLoaded(true);

setAccount(await getAccount());
$reloadOTP();
})
.catch(() => {
navigate("/login");
});
}, []);

useEffect(() => {}, []);

const onSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();

const data = new FormData(event.target as unknown as HTMLFormElement);
const token = data.get("token") as string;

if (!token || token.length !== 6) return;

const isVerified = await verifyOTP(token);
if (isVerified) setOTPUrl(null);
}, []);

const onDeleteOTP = async () => {
setOTPLoaded(false);
await deleteOTP();
$reloadOTP();
};

if (!loaded || !account) return <div>loading....</div>;

return (
<div>
<div>
<h2>Account</h2>
<p>{account.username}</p>
<p>{account.email}</p>
</div>
<div>
<h2>OTP</h2>
{otpLoaded &&
(otpUrl ? (
<form onSubmit={onSubmit}>
<img src={otpUrl} />
<input name="token" maxLength={6} />
<button>Verify</button>
</form>
) : (
<div>
OTP active <button onClick={onDeleteOTP}>Remove OTP</button>
</div>
))}
</div>
<div>
<h2>Actions</h2>
<LinkComponent to="/">Go to hotel</LinkComponent>
<p />
<LinkComponent to="/logout">Logout</LinkComponent>
<p />
</div>
</div>
);
};
1 change: 1 addition & 0 deletions app/client/src/modules/account/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "modules/account/account.component";
7 changes: 7 additions & 0 deletions app/client/src/modules/account/login.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@import "../../shared/styles/consts.module";

.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { HomeComponent } from "modules/home";
import { RedirectComponent } from "shared/components";
import { VerifyComponent } from "modules/verify";
import { LogoutComponent } from "modules/logout";
import { AccountComponent } from "modules/account";

const router = createBrowserRouter([
{
Expand All @@ -30,6 +31,10 @@ const router = createBrowserRouter([
path: "/logout",
Component: () => <LogoutComponent />,
},
{
path: "/account",
Component: () => <AccountComponent />,
},
{
path: "/",
Component: () => <HomeComponent />,
Expand Down
4 changes: 2 additions & 2 deletions app/client/src/modules/home/home.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { useApi } from "shared/hooks";

export const HomeComponent: React.FC = () => {
const [isReady, setIsReady] = useState(false);
const { refreshSession } = useApi();
const { refreshSession, getTicketId } = useApi();

useEffect(() => {
if (window.location.pathname === "/logout") return;

refreshSession()
refreshSession(getTicketId() ?? "refresh")
.then(({ redirectUrl }) => {
window.location.href = redirectUrl;
})
Expand Down
35 changes: 28 additions & 7 deletions app/client/src/modules/login/login.component.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import React, { FormEvent, useCallback, useState } from "react";
import React, { FormEvent, useCallback, useEffect, useState } from "react";
import { CaptchaComponent, LinkComponent } from "shared/components";
import { useApi } from "shared/hooks";
import styles from "./login.module.scss";
import { redirectToFallbackRedirectUrl } from "shared/utils/urls.utils";

import { useNavigate } from "react-router-dom";
export const LoginComponent: React.FC = () => {
const [submittedAt, setSubmittedAt] = useState<number>();
const [captchaId, setCaptchaId] = useState<string>();
const [loaded, setLoaded] = useState<boolean>(false);
const [showOTP, setShowOTP] = useState<boolean>(false);

const { login, refreshSession, getTicketId } = useApi();
let navigate = useNavigate();

const { login, getTicketId } = useApi();
useEffect(() => {
if (window.location.pathname === "/logout") return;

refreshSession(getTicketId())
.then(({ redirectUrl }) => {
if (!redirectUrl) return navigate("/account");
window.location.href = redirectUrl;
})
.catch(() => {
setLoaded(true);
console.log("Cannot refresh session!");
});
}, []);

const onSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
Expand All @@ -17,26 +33,31 @@ export const LoginComponent: React.FC = () => {
const data = new FormData(event.target as unknown as HTMLFormElement);
const email = data.get("email") as string;
const password = data.get("password") as string;
const otpToken = data.get("otpToken") as string;

login(email, password, captchaId)
login(email, password, captchaId, getTicketId(), otpToken)
.then(({ redirectUrl }) => {
if (!redirectUrl) return navigate("/account");

window.location.href = redirectUrl;
})
.catch(({ status }) => {
if (status === 441) setShowOTP(true);
setSubmittedAt(performance.now());
});
},
[captchaId],
[captchaId, getTicketId],
);

if (!getTicketId()) return redirectToFallbackRedirectUrl();
if (!loaded) return <div>loading...</div>;

return (
<div>
<form className={styles.form} onSubmit={onSubmit}>
<input name="email" placeholder="email" />
<input name="password" placeholder="password" type="password" />
<CaptchaComponent submittedAt={submittedAt} onResolve={setCaptchaId} />
{showOTP && <input name="otpToken" placeholder="otp" maxLength={6} />}
<button type="submit">Login</button>
</form>
<LinkComponent to="/register">/register</LinkComponent>
Expand Down
2 changes: 2 additions & 0 deletions app/client/src/shared/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./useApi";
export * from "./useAccount";
export * from "./useQR";
55 changes: 55 additions & 0 deletions app/client/src/shared/hooks/useAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useApi } from "./useApi";

export const useAccount = () => {
const { refreshSession, getSessionId, getToken } = useApi();

const $getHeaders = () => {
const headers = new Headers();
headers.append("sessionId", getSessionId());
headers.append("token", getToken());

return headers;
};

const getAccount = async () => {
const { data } = await fetch(`/api/v2/account`, {
headers: $getHeaders(),
}).then((response) => response.json());
return data;
};

const getOTP = async (): Promise<string> => {
const { data } = await fetch(`/api/v2/account/otp`, {
headers: $getHeaders(),
method: "POST",
}).then((response) => response.json());

return data?.uri;
};

const verifyOTP = async (token: string): Promise<boolean> => {
const { status } = await fetch(
`/api/v2/account/otp/verify?token=${token}`,
{
headers: $getHeaders(),
},
).then((response) => response.json());

return status === 200;
};

const deleteOTP = async () => {
await fetch(`/api/v2/account/otp`, {
headers: $getHeaders(),
method: "DELETE",
}).then((response) => response.json());
};

return {
getAccount,

getOTP,
verifyOTP,
deleteOTP,
};
};
51 changes: 40 additions & 11 deletions app/client/src/shared/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,37 @@ export const useApi = () => {

const clearSessionCookies = () => {
Cookies.remove("sessionId");
Cookies.remove("token");
Cookies.remove("refreshToken");
};

const getSessionId = () => Cookies.get("sessionId");
const getToken = () => Cookies.get("token");
const getRefreshToken = () => Cookies.get("refreshToken");

const setFallbackRedirectUrl = (redirectUrl: string) => {
const { href, search } = new URL(redirectUrl);
localStorage.setItem("fallbackRedirectUrl", href.replace(search, ""));
};

const login = (email: string, password: string, captchaId: string) =>
const login = (
email: string,
password: string,
captchaId: string,
ticketId?: string,
otpToken?: string,
) =>
new Promise((resolve, reject) => {
fetch("/api/v2/account/login", {
method: "POST",
body: JSON.stringify({
ticketId: getTicketId(),
ticketId,

email,
password,
captchaId,

otpToken,
}),
})
.then((data) => data.json())
Expand All @@ -38,23 +51,30 @@ export const useApi = () => {
expires: 7,
sameSite: "strict",
});
setFallbackRedirectUrl(data.redirectUrl);
if (data.redirectUrl) setFallbackRedirectUrl(data.redirectUrl);
else
Cookies.set("token", data.token, {
expires: 1,
sameSite: "strict",
});
resolve(data);
})
.catch(() => reject({ status: 600 }));
});

const refreshSession = () =>
const refreshSession = (
ticketId?: string,
): Promise<{ redirectUrl: string }> =>
new Promise((resolve, reject) => {
const sessionId = Cookies.get("sessionId");
const refreshToken = Cookies.get("refreshToken");
const sessionId = getSessionId();
const refreshToken = getRefreshToken();

if (!sessionId || !refreshToken) return reject();

fetch("/api/v2/account/refresh-session", {
method: "POST",
body: JSON.stringify({
ticketId: getTicketId(),
ticketId,

sessionId,
refreshToken,
Expand All @@ -72,7 +92,12 @@ export const useApi = () => {
expires: 7,
sameSite: "strict",
});
setFallbackRedirectUrl(data.redirectUrl);
if (data.redirectUrl) setFallbackRedirectUrl(data.redirectUrl);
else
Cookies.set("token", data.token, {
expires: 1,
sameSite: "strict",
});
return resolve(data);
}
clearSessionCookies();
Expand Down Expand Up @@ -106,8 +131,8 @@ export const useApi = () => {

const logout = () =>
new Promise((resolve) => {
const sessionId = Cookies.get("sessionId");
const refreshToken = Cookies.get("refreshToken");
const sessionId = getSessionId();
const refreshToken = getRefreshToken();

clearSessionCookies();

Expand Down Expand Up @@ -136,12 +161,16 @@ export const useApi = () => {
});

return {
getSessionId,
getRefreshToken,
getToken,
getTicketId,

login,
refreshSession,
register,
logout,
clearSessionCookies,
getTicketId,
verify,
};
};
Loading

0 comments on commit 97be0c6

Please sign in to comment.