From 8b25fda7f900dd641aa30cc7d7db6c83b1dab70c Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 20:55:05 +0800 Subject: [PATCH 01/15] make ts --- lib/__tests__/{cookie.test.js => cookie.test.ts} | 6 +----- lib/__tests__/{pkce.test.js => pkce.test.ts} | 0 2 files changed, 1 insertion(+), 5 deletions(-) rename lib/__tests__/{cookie.test.js => cookie.test.ts} (94%) rename lib/__tests__/{pkce.test.js => pkce.test.ts} (100%) diff --git a/lib/__tests__/cookie.test.js b/lib/__tests__/cookie.test.ts similarity index 94% rename from lib/__tests__/cookie.test.js rename to lib/__tests__/cookie.test.ts index afd8b6c..8d7e54b 100644 --- a/lib/__tests__/cookie.test.js +++ b/lib/__tests__/cookie.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeAll, afterAll, vi, afterEach } from 'vite import { saveCookie, getCookie, deleteCookie, clearAllCookies } from '../cookie' describe('cookie', () => { - let mockCookie = [] + let mockCookie: string[] = [] beforeAll(() => { vi.stubGlobal('document', { get cookie() { @@ -24,10 +24,6 @@ describe('cookie', () => { }) describe('saveCookie', () => { - test('throws error on empty name', () => { - expect(() => saveCookie()).toThrowError('Cookie name is required') - }) - test('saves empty value', () => { saveCookie('a', '') expect(document.cookie).toMatch('a=;') diff --git a/lib/__tests__/pkce.test.js b/lib/__tests__/pkce.test.ts similarity index 100% rename from lib/__tests__/pkce.test.js rename to lib/__tests__/pkce.test.ts From 95f27b96ba4584851a3c5258feea19732f01f750 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 20:55:13 +0800 Subject: [PATCH 02/15] fix act warnings --- src/hooks/__tests__/useGetAccessToken.test.ts | 79 +++++++------------ 1 file changed, 30 insertions(+), 49 deletions(-) diff --git a/src/hooks/__tests__/useGetAccessToken.test.ts b/src/hooks/__tests__/useGetAccessToken.test.ts index ab95cce..ab3ad4d 100644 --- a/src/hooks/__tests__/useGetAccessToken.test.ts +++ b/src/hooks/__tests__/useGetAccessToken.test.ts @@ -41,20 +41,16 @@ describe("useGetAccessToken", () => { const { result } = renderHook(() => useGetAccessToken()) - act(() => { - result.current.getATWithAuthCode("state", "code", "codeVerifier") + await act(async () => { + await result.current.getATWithAuthCode("state", "code", "codeVerifier") }) - expect(result.current.isLoading).toBeTruthy() - expect(result.current.error).toBeFalsy() - await vi.waitFor(() => { - expect(result.current.tokens).toEqual({ - accessToken: "access_token_jwt", - refreshToken: "refresh_token_jwt" - }) - expect(result.current.isLoading).toBeFalsy() - expect(result.current.error).toBeFalsy() + expect(result.current.tokens).toEqual({ + accessToken: "access_token_jwt", + refreshToken: "refresh_token_jwt" }) + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeFalsy() }) test("captures error and delete state cookie", async () => { @@ -70,22 +66,17 @@ describe("useGetAccessToken", () => { const { result } = renderHook(() => useGetAccessToken()) - act(() => { - result.current.getATWithAuthCode("state", "code", "codeVerifier") + await act(async () => { + await result.current.getATWithAuthCode("state", "code", "codeVerifier") }) - expect(result.current.isLoading).toBeTruthy() - expect(result.current.error).toBeNull() - - await vi.waitFor(() => { - expect(result.current.tokens).toEqual({ - accessToken: null, - refreshToken: null - }) - expect(result.current.isLoading).toBeFalsy() - expect(result.current.error).toBe("Bad Request") - expect(deleteStateCookie).toHaveBeenCalled() + expect(result.current.tokens).toEqual({ + accessToken: null, + refreshToken: null }) + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBe("Bad Request") + expect(deleteStateCookie).toHaveBeenCalled() }) }) describe("refresh token", () => { @@ -103,20 +94,15 @@ describe("useGetAccessToken", () => { const { result } = renderHook(() => useGetAccessToken()) - act(() => { - result.current.getATWithAuthCode("state", "code", "codeVerifier") + await act(async () => { + await result.current.getATWithRefreshToken("mock_refresh_token") }) - expect(result.current.isLoading).toBeTruthy() - expect(result.current.error).toBeFalsy() - - await vi.waitFor(() => { - expect(result.current.tokens).toEqual({ - accessToken: "access_token_jwt", - refreshToken: "refresh_token_jwt" - }) - expect(result.current.isLoading).toBeFalsy() - expect(result.current.error).toBeFalsy() + expect(result.current.tokens).toEqual({ + accessToken: "access_token_jwt", + refreshToken: "refresh_token_jwt" }) + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeFalsy() }) test("captures error and delete refresh token", async () => { @@ -132,22 +118,17 @@ describe("useGetAccessToken", () => { const { result } = renderHook(() => useGetAccessToken()) - act(() => { - result.current.getATWithRefreshToken("mock_refresh_token") + await act(async () => { + await result.current.getATWithRefreshToken("mock_refresh_token") }) - expect(result.current.isLoading).toBeTruthy() - expect(result.current.error).toBeNull() - - await vi.waitFor(() => { - expect(result.current.tokens).toEqual({ - accessToken: null, - refreshToken: null - }) - expect(result.current.isLoading).toBeFalsy() - expect(result.current.error).toBe("Bad Request") - expect(deleteRefreshToken).toHaveBeenCalled() + expect(result.current.tokens).toEqual({ + accessToken: null, + refreshToken: null }) + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBe("Bad Request") + expect(deleteRefreshToken).toHaveBeenCalled() }) }) }) From e4a457a879ea4465135ff0eb9c34ad1650b7b0b0 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 21:25:05 +0800 Subject: [PATCH 03/15] minor style fix --- src/App.module.css | 29 +++++++++++++++++++++++++--- src/App.tsx | 33 ++++++++++++++++---------------- src/components/LoginButton.tsx | 10 ++++------ src/index.css | 35 ++-------------------------------- 4 files changed, 48 insertions(+), 59 deletions(-) diff --git a/src/App.module.css b/src/App.module.css index 5c6f6ad..2edf4cf 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -1,8 +1,31 @@ -.app { -} - .app-loginBtn { position: absolute; top: 1rem; right: 1rem; } + +.debug-table { + width: 80vw; + text-align: left; + + font-family: monospace; + line-height: 1.5; +} +.debug-table tr:nth-of-type(odd) { + background-color: green; +} +.debug-table td { + padding: 1rem; +} + +.debug-itemName { + font-weight: bold; + vertical-align: baseline; + padding-right: 1rem; + width: 20%; +} +.debug-itemValue { + vertical-align: baseline; + word-break: break-all; + width: 80%; +} diff --git a/src/App.tsx b/src/App.tsx index 58d0fb0..59bf3df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,7 +39,7 @@ function App() { } else if (authStage.stage === AuthStage.BEFORE_AUTH_CODE) { getATWithAuthCode(authStage.state, authStage.code, authStage.codeVerifier) } - // once on mount only + // on stage change only // eslint-disable-next-line react-hooks/exhaustive-deps }, [authStage.stage]) @@ -49,6 +49,8 @@ function App() { state, code, codeVerifier, + cookie: document.cookie || null, + localStorage: JSON.stringify(localStorage, null, 2) } const isLoggedIn = authStage.stage === AuthStage.LOGGED_IN || authStage.stage === AuthStage.AFTER_AUTH_CODE @@ -61,22 +63,19 @@ function App() { : }

Stage: {authStage.stage}

-
-          {!isLoading && <>
-            
-              
-                {Object.entries(statuses).filter(([, v]) => !!v).map(([key, value]) => (
-                  
-                    
-                    
-                  
-                ))}
-              
-            
{key}{`${value}`}
- - } - {error &&
{error}
} -
+ {error &&
{error}
} + {!isLoading && <> + + + {Object.entries(statuses).map(([key, value]) => ( + + + + + ))} + +
{key}{`${value ?? ""}`}
+ } ) diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx index ca05252..490e1c7 100644 --- a/src/components/LoginButton.tsx +++ b/src/components/LoginButton.tsx @@ -8,12 +8,10 @@ const LoginButton = ({ className }: LoginButtonProps) => { return (
- {error} - - - + {error} +
) } diff --git a/src/index.css b/src/index.css index 2333a5d..5741327 100644 --- a/src/index.css +++ b/src/index.css @@ -24,10 +24,7 @@ a:hover { body { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; + padding: 1rem; } h1 { @@ -67,35 +64,7 @@ button:focus-visible { } } -#root { - height: 100vh; - padding: 15px; -} - -pre { - max-width: 80vw; - text-align: left; - overflow: hidden; - white-space: wrap; - word-wrap: normal; - font-size: 0.9em; - line-height: 1.5; - border-radius: 8px; -} - .error { color: red; -} - -.debug-longText { - max-width: 80vw; - overflow: hidden; - word-break: break-all; - margin-bottom: 10px; -} - -.debug-itemName { - font-weight: bold; - vertical-align: baseline; - padding-right: 15px; + margin: 0 1rem; } From ae2782ec19642caf465a5d9d89bd922174f5334e Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 21:34:05 +0800 Subject: [PATCH 04/15] color fix --- src/App.module.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/App.module.css b/src/App.module.css index 2edf4cf..d597ff2 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -12,7 +12,11 @@ line-height: 1.5; } .debug-table tr:nth-of-type(odd) { - background-color: green; + background-color: lightgreen; + + @media screen and (prefers-color-scheme: dark) { + background-color: green; + } } .debug-table td { padding: 1rem; From 8f4cab5fad50b2e1b8b7237ef5d0147d4f66fc47 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 21:34:16 +0800 Subject: [PATCH 05/15] add isloading state --- src/App.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 59bf3df..2959441 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,9 +62,9 @@ function App() { isLoggedIn ? : } -

Stage: {authStage.stage}

+

{authStage.stage}

{error &&
{error}
} - {!isLoading && <> + {!isLoading && ( {Object.entries(statuses).map(([key, value]) => ( @@ -74,8 +74,8 @@ function App() { ))} -
- } + )} + {isLoading &&

Loading...

} ) From a920787d9108fc2b7eab8519ad90abfebc305a3d Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 21:44:40 +0800 Subject: [PATCH 06/15] add loading state --- src/components/LoginButton.tsx | 13 +++++++++++-- src/components/LogoutButton.tsx | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx index 490e1c7..e96fd45 100644 --- a/src/components/LoginButton.tsx +++ b/src/components/LoginButton.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from 'react' + import useInitPKCE from '@/hooks/useInitPKCE' type LoginButtonProps = { @@ -5,12 +7,19 @@ type LoginButtonProps = { } const LoginButton = ({ className }: LoginButtonProps) => { const { error, onLogin } = useInitPKCE() + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + const handleRouteChangeStart = () => setIsLoading(true) + window.addEventListener('beforeunload', handleRouteChangeStart) + return () => window.removeEventListener('beforeunload', handleRouteChangeStart) + }, []) return (
{error} -
) diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx index 88f5750..de208da 100644 --- a/src/components/LogoutButton.tsx +++ b/src/components/LogoutButton.tsx @@ -1,7 +1,7 @@ -import { deleteRefreshToken } from "@/utils/refreshToken" -import { useCallback } from "react" +import { useCallback, useEffect, useState } from "react" import { reload } from '@/utils/route' +import { deleteRefreshToken } from "@/utils/refreshToken" type LogoutButtonProps = { className?: string @@ -12,11 +12,19 @@ const LogoutButton = ({ className }: LogoutButtonProps) => { reload() }, []) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + const handleRouteChangeStart = () => setIsLoading(true) + window.addEventListener('beforeunload', handleRouteChangeStart) + return () => window.removeEventListener('beforeunload', handleRouteChangeStart) + }, []) + return (
-
From 7f0e52223c57a8ad25625bfe2a4f43056d2f8a87 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 21:44:46 +0800 Subject: [PATCH 07/15] fix names --- src/hooks/__tests__/useGetAccessToken.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/__tests__/useGetAccessToken.test.ts b/src/hooks/__tests__/useGetAccessToken.test.ts index ab3ad4d..5cb4e3c 100644 --- a/src/hooks/__tests__/useGetAccessToken.test.ts +++ b/src/hooks/__tests__/useGetAccessToken.test.ts @@ -26,7 +26,7 @@ describe("useGetAccessToken", () => { }) - describe("auth token", () => { + describe("getATWithAuthCode", () => { test("returns access tokens", async () => { const mockResponse = { access_token: 'access_token_jwt', @@ -79,7 +79,7 @@ describe("useGetAccessToken", () => { expect(deleteStateCookie).toHaveBeenCalled() }) }) - describe("refresh token", () => { + describe("getATWithRefreshToken", () => { test("returns access tokens", async () => { const mockResponse = { access_token: 'access_token_jwt', From c7f9805418783582d3c5f4ff48b01f6c5a40872a Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 22:05:38 +0800 Subject: [PATCH 08/15] minor refactor --- src/App.tsx | 6 +----- src/components/LoginButton.tsx | 23 ++++++++++++++++++---- src/components/LogoutButton.tsx | 34 --------------------------------- 3 files changed, 20 insertions(+), 43 deletions(-) delete mode 100644 src/components/LogoutButton.tsx diff --git a/src/App.tsx b/src/App.tsx index 2959441..28dcf54 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,7 +12,6 @@ import useAuthContextValue from '@/hooks/useAuthContextValue' import useGetAccessToken from '@/hooks/useGetAccessToken' import s from './App.module.css' -import LogoutButton from './components/LogoutButton' import { getAuthStage } from './utils/authStage' function App() { @@ -58,10 +57,7 @@ function App() { return (
- { - isLoggedIn ? - : - } +

{authStage.stage}

{error &&
{error}
} {!isLoading && ( diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx index e96fd45..928bbe8 100644 --- a/src/components/LoginButton.tsx +++ b/src/components/LoginButton.tsx @@ -1,25 +1,40 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { useContextSelector } from 'use-context-selector' import useInitPKCE from '@/hooks/useInitPKCE' +import AuthContext from '@/contexts/AuthContext' +import { deleteRefreshToken } from '@/utils/refreshToken' +import { reload } from '@/utils/route' type LoginButtonProps = { className?: string } const LoginButton = ({ className }: LoginButtonProps) => { - const { error, onLogin } = useInitPKCE() + const accessToken = useContextSelector(AuthContext, ctx => ctx.accessToken) + const [isLoading, setIsLoading] = useState(false) + const { error, onLogin } = useInitPKCE() + const handleLogout = useCallback(() => { + deleteRefreshToken() + reload() + }, []) + useEffect(() => { const handleRouteChangeStart = () => setIsLoading(true) window.addEventListener('beforeunload', handleRouteChangeStart) return () => window.removeEventListener('beforeunload', handleRouteChangeStart) }, []) + const isLoggedIn = !!accessToken + return (
{error} -
) diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx deleted file mode 100644 index de208da..0000000 --- a/src/components/LogoutButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useCallback, useEffect, useState } from "react" - -import { reload } from '@/utils/route' -import { deleteRefreshToken } from "@/utils/refreshToken" - -type LogoutButtonProps = { - className?: string -} -const LogoutButton = ({ className }: LogoutButtonProps) => { - const handleLogout = useCallback(() => { - deleteRefreshToken() - reload() - }, []) - - const [isLoading, setIsLoading] = useState(false) - - useEffect(() => { - const handleRouteChangeStart = () => setIsLoading(true) - window.addEventListener('beforeunload', handleRouteChangeStart) - return () => window.removeEventListener('beforeunload', handleRouteChangeStart) - }, []) - - return ( -
- - - -
- ) -} - -export default LogoutButton From 5b03a7609e6d02e1d547a69923540c3ab699bbdb Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 23:29:52 +0800 Subject: [PATCH 09/15] test App and fixes --- src/App.test.tsx | 16 --- src/App.tsx | 4 +- src/__snapshots__/App.test.tsx.snap | 115 ++++++++++++++++++ src/__tests__/App.test.tsx | 19 +++ src/__tests__/__snapshots__/App.test.tsx.snap | 113 +++++++++++++++++ 5 files changed, 248 insertions(+), 19 deletions(-) delete mode 100644 src/App.test.tsx create mode 100644 src/__snapshots__/App.test.tsx.snap create mode 100644 src/__tests__/App.test.tsx create mode 100644 src/__tests__/__snapshots__/App.test.tsx.snap diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index 39c3d01..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ - -import { test, expect } from 'vitest' -import { render } from '@testing-library/react' -import App from './App' - -test('runs vitest', () => { - expect(1).toBe(1) -}) - -test('renders text', () => { - const wrapper = render() - expect(wrapper).toBeTruthy() - - const { getByText } = wrapper - expect(getByText('Login')).toBeTruthy() -}) diff --git a/src/App.tsx b/src/App.tsx index 28dcf54..4320d2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import AuthContext from '@/contexts/AuthContext' import { getPKCEStatus } from '@/utils/auth' +import { getAuthStage } from '@/utils/authStage' import { clearSearchParams, getSearchParams } from '@/utils/route' import { deleteStateCookie } from '@/utils/stateCookie' import { AuthStage } from '@/types' @@ -12,7 +13,6 @@ import useAuthContextValue from '@/hooks/useAuthContextValue' import useGetAccessToken from '@/hooks/useGetAccessToken' import s from './App.module.css' -import { getAuthStage } from './utils/authStage' function App() { const { state, code } = getSearchParams() @@ -51,8 +51,6 @@ function App() { cookie: document.cookie || null, localStorage: JSON.stringify(localStorage, null, 2) } - const isLoggedIn = authStage.stage === AuthStage.LOGGED_IN - || authStage.stage === AuthStage.AFTER_AUTH_CODE return ( diff --git a/src/__snapshots__/App.test.tsx.snap b/src/__snapshots__/App.test.tsx.snap new file mode 100644 index 0000000..83434c2 --- /dev/null +++ b/src/__snapshots__/App.test.tsx.snap @@ -0,0 +1,115 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`App > renders with snapshot 1`] = ` +
+
+
+ + +
+

+ Logged Out +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ accessToken + + <empty> +
+ refreshToken + + <empty> +
+ state + + <empty> +
+ code + + <empty> +
+ codeVerifier + + <empty> +
+ cookie + + <empty> +
+ localStorage + + {} +
+
+
+`; diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx new file mode 100644 index 0000000..af61b5d --- /dev/null +++ b/src/__tests__/App.test.tsx @@ -0,0 +1,19 @@ + +import { test, expect, describe } from 'vitest' +import { render } from '@testing-library/react' +import App from '../App' + +test('runs vitest', () => { + expect(1).toBe(1) +}) + +describe('App', () => { + test('renders with snapshot', () => { + const screen = render() + expect(screen.container.children[0]).toMatchSnapshot() + }) +}) + + + + diff --git a/src/__tests__/__snapshots__/App.test.tsx.snap b/src/__tests__/__snapshots__/App.test.tsx.snap new file mode 100644 index 0000000..dbb8d0a --- /dev/null +++ b/src/__tests__/__snapshots__/App.test.tsx.snap @@ -0,0 +1,113 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`App > renders with snapshot 1`] = ` +
+
+ + +
+

+ Logged Out +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ accessToken + + <empty> +
+ refreshToken + + <empty> +
+ state + + <empty> +
+ code + + <empty> +
+ codeVerifier + + <empty> +
+ cookie + + <empty> +
+ localStorage + + {} +
+
+`; From 19594b24acf4207847e1217ffbee88c0bc11faea Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 23:30:05 +0800 Subject: [PATCH 10/15] test LoginButton --- src/components/LoginButton.tsx | 19 ++-- src/components/__tests__/LoginButton.test.tsx | 86 +++++++++++++++++++ .../__snapshots__/LoginButton.test.tsx.snap | 14 +++ 3 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 src/components/__tests__/LoginButton.test.tsx create mode 100644 src/components/__tests__/__snapshots__/LoginButton.test.tsx.snap diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx index 928bbe8..eee86b1 100644 --- a/src/components/LoginButton.tsx +++ b/src/components/LoginButton.tsx @@ -4,21 +4,24 @@ import { useContextSelector } from 'use-context-selector' import useInitPKCE from '@/hooks/useInitPKCE' import AuthContext from '@/contexts/AuthContext' import { deleteRefreshToken } from '@/utils/refreshToken' -import { reload } from '@/utils/route' type LoginButtonProps = { className?: string } const LoginButton = ({ className }: LoginButtonProps) => { - const accessToken = useContextSelector(AuthContext, ctx => ctx.accessToken) - + const accessToken = useContextSelector( + AuthContext, ctx => ctx.accessToken + ) + const setAccessToken = useContextSelector( + AuthContext, ctx => ctx.setAccessToken + ) const [isLoading, setIsLoading] = useState(false) const { error, onLogin } = useInitPKCE() const handleLogout = useCallback(() => { deleteRefreshToken() - reload() - }, []) + setAccessToken(null) + }, [setAccessToken]) useEffect(() => { const handleRouteChangeStart = () => setIsLoading(true) @@ -27,14 +30,16 @@ const LoginButton = ({ className }: LoginButtonProps) => { }, []) const isLoggedIn = !!accessToken + const buttonText = isLoggedIn ? 'Logout' : 'Login' + const handleButtonClick = isLoggedIn ? handleLogout : onLogin return (
{error}
) diff --git a/src/components/__tests__/LoginButton.test.tsx b/src/components/__tests__/LoginButton.test.tsx new file mode 100644 index 0000000..523e335 --- /dev/null +++ b/src/components/__tests__/LoginButton.test.tsx @@ -0,0 +1,86 @@ + +import { test, expect, vi, describe, Mock } from 'vitest' +import { render, fireEvent, act } from '@testing-library/react' + +import LoginButton from '../LoginButton' +import config from '@/config' + +const mocks = vi.hoisted(() => { + return { + useContextSelector: vi.fn(), + deleteRefreshToken: vi.fn(), + setAccessToken: vi.fn() + } +}) +vi.mock('use-context-selector', async (importOriginal) => { + const actual = await importOriginal() + return ({ + ...(actual as Record), + useContextSelector: mocks.useContextSelector + }) +}) +vi.mock('@/utils/refreshToken', async (importOriginal) => { + const actual = await importOriginal() + return ({ + ...(actual as Record), + deleteRefreshToken: mocks.deleteRefreshToken + }) +}) + +describe('LoginButton', () => { + test('renders with snapshot', () => { + mocks.useContextSelector.mockReturnValue(null) + + const screen = render() + expect(screen.container.children[0]).toMatchSnapshot() + }) + + test('logs in and redirects to login url', async () => { + mocks.useContextSelector.mockReturnValue(null) + vi.spyOn(location, 'replace') + + const { getByText } = render() + fireEvent.click(getByText(/Login/)) + + await vi.waitFor(() => { + expect(location.replace).toHaveBeenCalled() + + const urlString = (location.replace as Mock).mock.calls[0][0] + const url = new URL(urlString) + expect(url.origin + url.pathname).toBe(config.LOGIN_URL) + }) + }) + + test('logs out and clears tokens', async () => { + mocks.useContextSelector.mockReturnValueOnce('access_token_jwt') + mocks.useContextSelector.mockReturnValueOnce(mocks.setAccessToken) + + const { getByText } = render() + expect(getByText(/Logout/)) + + fireEvent.click(getByText(/Logout/)) + expect(mocks.deleteRefreshToken).toHaveBeenCalledWith() + expect(mocks.setAccessToken).toHaveBeenCalledWith(null) + }) + + test('shows loading state', async () => { + mocks.useContextSelector.mockReturnValue(null) + + const { getByText } = render() + expect(getByText(/Login/)) + + act(() => { + const event = new Event('beforeunload', { + bubbles: true, + cancelable: true, + }) + window.dispatchEvent(event) + }) + + expect(getByText(/Loading/)) + }) +}) + + + + diff --git a/src/components/__tests__/__snapshots__/LoginButton.test.tsx.snap b/src/components/__tests__/__snapshots__/LoginButton.test.tsx.snap new file mode 100644 index 0000000..bbb4dd4 --- /dev/null +++ b/src/components/__tests__/__snapshots__/LoginButton.test.tsx.snap @@ -0,0 +1,14 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LoginButton > renders with snapshot 1`] = ` +
+ + +
+`; From e1b3d49a84fde5ed28ff75dd3276d170df7b2384 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 23:30:13 +0800 Subject: [PATCH 11/15] fix cookie test --- lib/__tests__/cookie.test.ts | 12 +++++++----- lib/cookie.ts | 10 ++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/__tests__/cookie.test.ts b/lib/__tests__/cookie.test.ts index 8d7e54b..f8f451f 100644 --- a/lib/__tests__/cookie.test.ts +++ b/lib/__tests__/cookie.test.ts @@ -86,11 +86,13 @@ describe('cookie', () => { describe('clearAllCookies', () => { test('clears all cookies', () => { - saveCookie('a', '12345') - saveCookie('b', '54321') - clearAllCookies() - expect(document.cookie).toMatch('a=;') - expect(document.cookie).toMatch('b=;') + saveCookie('state-a', '12345') + saveCookie('state-b', '54321') + saveCookie('c', '54321') + clearAllCookies("state-") + expect(document.cookie).toMatch('state-a=;') + expect(document.cookie).toMatch('state-b=;') + expect(document.cookie).toMatch('c=54321;') }) }) }) diff --git a/lib/cookie.ts b/lib/cookie.ts index 091d26f..5663c12 100644 --- a/lib/cookie.ts +++ b/lib/cookie.ts @@ -19,12 +19,14 @@ export const deleteCookie = (name: string) => { document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT` } -export const clearAllCookies = () => { +export const clearAllCookies = (prefix: string) => { const cookies = document.cookie.split(";") for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i] - const eqPos = cookie.indexOf("=") - const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie - document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT` + if (cookie.trim().startsWith(prefix)) { + const eqPos = cookie.indexOf("=") + const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT` + } } } From 39b4b800ad3132f05946ad124ba1fb2289dc6013 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 23:30:20 +0800 Subject: [PATCH 12/15] remove unused --- src/utils/route.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/utils/route.ts b/src/utils/route.ts index ae2e18e..55e2453 100644 --- a/src/utils/route.ts +++ b/src/utils/route.ts @@ -14,5 +14,3 @@ export const getSearchParams = () => { export const redirect = (url: string) => { window.location.replace(url) } - -export const reload = () => window.location.reload() From 49bc32a8eb0e4014ead5086a49c8cafacd0004a7 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 23:30:52 +0800 Subject: [PATCH 13/15] fix css module name in tests --- vite.config.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index ba76fb0..5f981d6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,12 +11,17 @@ export default defineConfig({ ] }, test: { + css: { + modules: { + classNameStrategy: 'non-scoped' + } + }, globals: true, - environment: 'happy-dom', - // browser: { - // enabled: true, - // name: 'chrome', - // headless: true - // } + environment: 'happy-dom' + }, + browser: { + enabled: true, + name: 'chrome', + headless: true } }) From 00d0c7c3bb4468287290fe8843d0c18d462de3f5 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 23:31:43 +0800 Subject: [PATCH 14/15] satisfy linter --- src/contexts/AuthContext.ts | 2 +- src/hooks/useAuthContextValue.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/contexts/AuthContext.ts b/src/contexts/AuthContext.ts index d4c1d5c..fe94054 100644 --- a/src/contexts/AuthContext.ts +++ b/src/contexts/AuthContext.ts @@ -3,7 +3,7 @@ import { createContext } from "use-context-selector" export type AuthContextType = { readonly accessToken: string | null; readonly refreshToken: string | null; - setAccessToken(token: string): void; + setAccessToken(token: string | null): void; setRefreshToken(token: string): void; } diff --git a/src/hooks/useAuthContextValue.ts b/src/hooks/useAuthContextValue.ts index 7c0430f..bdb784f 100644 --- a/src/hooks/useAuthContextValue.ts +++ b/src/hooks/useAuthContextValue.ts @@ -22,7 +22,7 @@ const useAuthContextValue = (tokens: AuthTokens) => { get refreshToken(): string | null { return getRefreshToken() }, - setAccessToken(token: string) { + setAccessToken(token: string | null) { setAccessToken(token) }, setRefreshToken(token: string) { From 0e248993dfa950452ac1fff63c306b60d52cc936 Mon Sep 17 00:00:00 2001 From: Alvin Ng <243186+alvinsj@users.noreply.github.com> Date: Mon, 10 Jun 2024 23:37:02 +0800 Subject: [PATCH 15/15] update readme --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index e69de29..6f2e2ec 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,30 @@ +## Refresh Token Rotation Demo + +### Development + +1. Add a new file `.env.local` + +```sh +VITE_AUTH_URL=https://auth-api.app/api/authorize +VITE_TOKEN_URL=https://auth-api.app/api/oauth/token +VITE_BASE_URL=http://localhost:5173 +``` + +2. Install packages + +```sh +$ npm install +``` + +3. Run development server, default to http://localhost:5173 + +```sh +$ npm run dev +``` + +### Test + +```sh +$ npm run test +$ npm run test:coverage # display coverage +```