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 (
-
- Logout
+
+ {isLoading ? 'Loading...' : 'Logout'}
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}
-
- {isLoading ? 'Loading...' : 'Login'}
+
+ {isLoading ? 'Loading...' : (isLoggedIn ? 'Logout' : 'Login')}
)
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 (
-
-
-
- {isLoading ? 'Loading...' : 'Logout'}
-
-
-
- )
-}
-
-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`] = `
+
+
+
+
+
+ Login
+
+
+
+ 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`] = `
+
+
+
+
+ Login
+
+
+
+ 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}
- {isLoading ? 'Loading...' : (isLoggedIn ? 'Logout' : 'Login')}
+ {isLoading ? 'Loading...' : buttonText}
)
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`] = `
+
+
+
+ Login
+
+
+`;
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
+```