diff --git a/.eslintrc.json b/.eslintrc.json index a7e87b3..1b59e82 100755 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,6 +20,7 @@ "plugins": ["ordered-imports", "react", "@typescript-eslint"], "rules": { "react/react-in-jsx-scope": "off", + "compat/compat": "error", "ordered-imports/ordered-imports": [ "error", { diff --git a/package.json b/package.json index 971d6be..68fba8d 100755 --- a/package.json +++ b/package.json @@ -49,5 +49,9 @@ "prettier": "^2.7.1", "typescript": "^4.6.4", "vite": "^3.1.0" - } + }, + "browserslist": [ + "defaults", + "not op_mini all" + ] } diff --git a/src/App.tsx b/src/App.tsx index 6bc14f4..ddde9a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import {CssBaseline, ThemeProvider} from "@mui/material" import {queryClient} from "~/constants/react-query" import {lightTheme} from "~/constants/themes" import {getServerSettings} from "~/apis" +import AuthContextProvider from "~/AuthContext/AuthContextProvider" import RootRoute from "~/routes/Root" import SignupRoute from "~/routes/SignupRoute" import SingleElementRoute from "~/routes/SingleElementRoute" @@ -43,8 +44,10 @@ export default function App(): ReactElement { - - + + + + diff --git a/src/AuthContext/AuthContext.ts b/src/AuthContext/AuthContext.ts new file mode 100644 index 0000000..a2fa2a2 --- /dev/null +++ b/src/AuthContext/AuthContext.ts @@ -0,0 +1,37 @@ +import {createContext} from "react" + +import {User} from "~/server-types" + +interface AuthContextTypeBase { + user: User | null + isAuthenticated: boolean + login: (user: User) => Promise + logout: () => void +} + +interface AuthContextTypeAuthenticated { + user: User + isAuthenticated: true +} + +interface AuthContextTypeUnauthenticated { + user: null + isAuthenticated: false +} + +export type AuthContextType = + | AuthContextTypeBase + | (AuthContextTypeAuthenticated & AuthContextTypeUnauthenticated) + +const AuthContext = createContext({ + user: null, + isAuthenticated: false, + login: () => { + throw new Error("login() not implemented") + }, + logout: () => { + throw new Error("logout() not implemented") + }, +}) + +export default AuthContext diff --git a/src/AuthContext/AuthContextProvider.tsx b/src/AuthContext/AuthContextProvider.tsx new file mode 100644 index 0000000..d9b6e19 --- /dev/null +++ b/src/AuthContext/AuthContextProvider.tsx @@ -0,0 +1,55 @@ +import {ReactElement, ReactNode, useCallback, useMemo} from "react" +import {AxiosError} from "axios" +import {useLocalStorage} from "react-use" + +import {useMutation} from "@tanstack/react-query" + +import {User} from "~/server-types" +import {RefreshTokenResult, logout as logoutUser, refreshToken} from "~/apis" + +import AuthContext, {AuthContextType} from "./AuthContext" + +export interface AuthContextProviderProps { + children: ReactNode +} + +export default function AuthContextProvider({ + children, +}: AuthContextProviderProps): ReactElement { + const [user, setUser] = useLocalStorage( + "_global-context-auth-user", + null, + ) + + const logout = useCallback(async (forceLogout = true) => { + setUser(null) + + if (forceLogout) { + await logoutUser() + } + }, []) + + const login = useCallback(async (user: User) => { + setUser(user) + }, []) + + const {mutateAsync: refresh} = useMutation< + RefreshTokenResult, + AxiosError, + void + >(refreshToken, { + onError: () => logout(false), + }) + + const value = useMemo( + () => ({ + user: user ?? null, + login, + logout, + isAuthenticated: user !== null, + }), + [refresh, login, logout], + ) + + return {children} +} diff --git a/src/apis/get-server-settings.ts b/src/apis/get-server-settings.ts index 81d8ef0..63dab47 100644 --- a/src/apis/get-server-settings.ts +++ b/src/apis/get-server-settings.ts @@ -1,6 +1,6 @@ import axios from "axios" -import {ServerSettings} from "~/types" +import {ServerSettings} from "~/server-types" export default async function getServerSettings(): Promise { return (await axios.get(`${import.meta.env.VITE_SERVER_BASE_URL}/settings`)) diff --git a/src/apis/helpers/parse-user.ts b/src/apis/helpers/parse-user.ts new file mode 100644 index 0000000..1ece2ed --- /dev/null +++ b/src/apis/helpers/parse-user.ts @@ -0,0 +1,20 @@ +import {User} from "~/server-types" + +export default function parseUser(user: any): User { + return { + id: user.id, + createdAt: new Date(user.created_at), + email: { + address: user.email.address, + isVerified: user.email.is_verified, + }, + preferences: { + aliasRemoveTrackers: user.preferences.alias_remove_trackers, + aliasCreateMailReport: user.preferences.alias_create_mail_report, + aliasProxyImages: user.preferences.alias_proxy_images, + aliasImageProxyFormat: user.preferences.alias_image_proxy_format, + aliasImageProxyUserAgent: + user.preferences.alias_image_proxy_user_agent, + }, + } +} diff --git a/src/apis/index.ts b/src/apis/index.ts index 4b7beea..f588252 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -8,3 +8,7 @@ export * from "./validate-email" export {default as validateEmail} from "./validate-email" export * from "./resend-email-verification-code" export {default as resendEmailVerificationCode} from "./resend-email-verification-code" +export * from "./refresh-token" +export {default as refreshToken} from "./refresh-token" +export * from "./logout" +export {default as logout} from "./logout" diff --git a/src/apis/logout.ts b/src/apis/logout.ts new file mode 100644 index 0000000..e949f9a --- /dev/null +++ b/src/apis/logout.ts @@ -0,0 +1,11 @@ +import axios from "axios" + +import {MinimumServerResponse} from "~/server-types" + +export default async function logout(): Promise { + const {data} = await axios.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/auth/logout`, + ) + + return data +} diff --git a/src/apis/refresh-token.ts b/src/apis/refresh-token.ts new file mode 100644 index 0000000..9f80f06 --- /dev/null +++ b/src/apis/refresh-token.ts @@ -0,0 +1,16 @@ +import axios from "axios" + +import {User} from "~/server-types" + +export interface RefreshTokenResult { + user: User + detail: string +} + +export default async function refreshToken(): Promise { + const {data} = await axios.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/api/refresh-token`, + ) + + return data +} diff --git a/src/apis/validate-email.ts b/src/apis/validate-email.ts index e73a345..5f1b627 100644 --- a/src/apis/validate-email.ts +++ b/src/apis/validate-email.ts @@ -1,6 +1,7 @@ import axios from "axios" -import {AuthenticationDetails} from "~/types" +import {AuthenticationDetails} from "~/server-types" +import parseUser from "~/apis/helpers/parse-user" export interface ValidateEmailData { email: string @@ -18,6 +19,10 @@ export default async function validateEmail({ token: token, }, ) + console.log(data) - return data + return { + ...data, + user: parseUser(data.user), + } } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a71313c..5da00ae 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,4 @@ export * from "./use-interval-update" export {default as useIntervalUpdate} from "./use-interval-update" +export * from "./use-query-params" +export {default as useQueryParams} from "./use-query-params" diff --git a/src/hooks/use-query-params.ts b/src/hooks/use-query-params.ts new file mode 100644 index 0000000..353e894 --- /dev/null +++ b/src/hooks/use-query-params.ts @@ -0,0 +1,18 @@ +import {useMemo} from "react" +import {useLocation} from "react-router-dom" + +export default function useQueryParams(): T { + const location = useLocation() + + return useMemo(() => { + const params = new URLSearchParams(location.search) + + const result: Record = {} + + for (const [key, value] of params) { + result[key] = value + } + + return result as T + }, [location.search]) +} diff --git a/src/routes/VerifyEmailRoute.tsx b/src/routes/VerifyEmailRoute.tsx index db1b87a..1b20b92 100644 --- a/src/routes/VerifyEmailRoute.tsx +++ b/src/routes/VerifyEmailRoute.tsx @@ -1,19 +1,25 @@ import * as yup from "yup" -import {useLoaderData, useParams} from "react-router-dom" +import {useLoaderData, useNavigate} from "react-router-dom" import {useAsync} from "react-use" import {MdCancel} from "react-icons/md" -import React, {ReactElement} from "react" +import {AxiosError} from "axios" +import React, {ReactElement, useContext} from "react" import {Grid, Paper, Typography, useTheme} from "@mui/material" +import {useMutation} from "@tanstack/react-query" -import {ServerSettings} from "~/server-types" -import {validateEmail} from "~/apis" +import {AuthenticationDetails, ServerSettings} from "~/server-types" +import {ValidateEmailData, validateEmail} from "~/apis" +import {useQueryParams} from "~/hooks" +import AuthContext from "~/AuthContext/AuthContext" const emailSchema = yup.string().email() export default function VerifyEmailRoute(): ReactElement { const theme = useTheme() - const {email, token} = useParams<{ + const navigate = useNavigate() + const {login} = useContext(AuthContext) + const {email, token} = useQueryParams<{ email: string token: string }>() @@ -29,18 +35,27 @@ export default function VerifyEmailRoute(): ReactElement { // Check token only contains chars from `serverSettings.email_verification_chars` const chars = serverSettings.email_verification_chars.split("") - return token.split("").every(chars.includes) + + return token.split("").every(char => chars.includes(char)) }) + const {mutateAsync: verifyEmail} = useMutation< + AuthenticationDetails, + AxiosError, + ValidateEmailData + >(validateEmail, { + onSuccess: async ({user}) => { + await login(user) + navigate("/") + }, + }) const {loading} = useAsync(async () => { await emailSchema.validate(email) await tokenSchema.validate(token) - await validateEmail({ - token: token as string, - email: email as string, + await verifyEmail({ + email, + token, }) - - return true }, [email, token]) return ( @@ -82,7 +97,7 @@ export default function VerifyEmailRoute(): ReactElement { component="p" align="center" > - Sorry, but this token is invalid. + Sorry, but this verification code is invalid. diff --git a/src/server-types.d.ts b/src/server-types.d.ts index 8b99bc7..77b51ac 100644 --- a/src/server-types.d.ts +++ b/src/server-types.d.ts @@ -30,8 +30,8 @@ export interface User { } export interface AuthenticationDetails { - access_token: string - refresh_token: string + user: User + detail: string } export interface ServerSettings {