diff --git a/packages/web/src/app-router/AppRouter.tsx b/packages/web/src/app-router/AppRouter.tsx index 95c54dd..1aebefe 100644 --- a/packages/web/src/app-router/AppRouter.tsx +++ b/packages/web/src/app-router/AppRouter.tsx @@ -30,7 +30,6 @@ export const AppRouter = () => { - , 'tokens'>; - setSession: (session?: SessionContextT['session']) => void; + setupSession: (sessionID: string) => void; authedFetch: (url: string, opts?: RequestInit) => Promise; isAuthenticated: boolean; + sessionState: SessionState; + sessionID?: SessionData['sessionID']; + session: { + user?: SessionData['user']; + guilds?: SessionData['guilds']; + }; }; const SessionContext = React.createContext({ + sessionState: SessionState.NoAuth, + sessionID: undefined, isAuthenticated: false, - setSession: () => {}, + session: { + user: undefined, + guilds: undefined, + }, + setupSession: () => {}, authedFetch: async () => { return new Response(); }, @@ -20,84 +46,128 @@ const SessionContext = React.createContext({ export const useSessionContext = () => React.useContext(SessionContext); export const SessionContextProvider = (props: { children: React.ReactNode }) => { - const api = useApiContext(); - const [session, setSession] = React.useState(undefined); - - const sessionContextValue: SessionContextT = React.useMemo( - () => ({ - session, - setSession, - authedFetch: (url: string, opts?: RequestInit) => { - return api.fetch(url, { - ...opts, - headers: { - ...opts?.headers, - authorization: session?.sessionID - ? `Bearer ${session?.sessionID}` - : undefined, - }, - }); - }, - isAuthenticated: !!session && !!session.sessionID && !!session.user, - }), - [session, api] + const { fetch } = useApiContext(); + const [sessionID, setSessionID] = React.useState( + undefined ); + const [sessionState, setSessionState] = React.useState( + SessionState.NoAuth + ); + const [session, setSession] = React.useState({ + user: undefined, + guilds: undefined, + }); + const [lock, setLock] = React.useState(false); - React.useEffect(() => { - // No session is set, do we have one available? - if (!sessionContextValue.session || !sessionContextValue.session.sessionID) { - // We may have the full state in session storage... - const storedSessionData = sessionStorage.getItem('rp_session_data'); - if (storedSessionData) { - try { - setSession(JSON.parse(storedSessionData)); - return; - } catch (e) { - // Oops, this data is wrong. - } + // Possible flows: + /* + if no key, check if key available in LS + + No session: + no key + isAuth = false + + Half session: + have key + lock = true + isAuth = false + fetch cached in SS _OR_ syncSession() + lock = false + + Full session + have session + isAuth = true + */ + + const sessionContextValue: SessionContextT = { + sessionID, + session, + sessionState, + isAuthenticated: sessionState === SessionState.FullAuth, + setupSession: async (newID: string) => { + setSessionID(newID); + setSessionState(SessionState.HalfAuth); + saveSessionKey(newID); + }, + authedFetch: async (url: string, init?: RequestInit): Promise => { + if (sessionID) { + init = { + ...init, + headers: { + ...init?.headers, + authorization: `Bearer ${sessionID}`, + }, + }; } - // But if not, we have the key, maybe? - const storedSessionID = localStorage.getItem('rp_session_key'); - if (storedSessionID && storedSessionID !== '') { - setSession({ sessionID: storedSessionID }); + return fetch(url, init); + }, + }; + + const { setupSession, authedFetch } = sessionContextValue; + + // Local storage sync on NoAuth + React.useEffect(() => { + if (!sessionID) { + const storedKey = getSessionKey(); + if (!storedKey) { return; } - // If we hit here, we're definitely not authenticated. + setupSession(storedKey); + } + }, [sessionID, setupSession]); + + // Sync session data on HalfAuth + React.useEffect(() => { + if (lock) { + console.warn('hit syncSession lock'); return; } - // If a session is set and it's not stored, set it now. - if ( - localStorage.getItem('rp_session_key') !== sessionContextValue.session.sessionID - ) { - localStorage.setItem('rp_session_key', sessionContextValue.session.sessionID || ''); - } + if (sessionState === SessionState.HalfAuth) { + setLock(true); - // Session is set, but we don't have data. Server sup? - if (sessionContextValue.session.sessionID && !sessionContextValue.session.user) { + // Use cached session + const storedData = getSessionData(); + if (storedData && storedData?.sessionID === sessionID) { + setSession(storedData.session); + setSessionState(SessionState.FullAuth); + setLock(false); + return; + } + + // If no cached session, let's grab it from server const syncSession = async () => { - const response = await sessionContextValue.authedFetch('/get-session'); - if (response.status !== 200) { - console.error('get-session failed', { response }); - clearSessionData(); - return; + try { + const serverSession = await fetchSession(authedFetch); + if (!serverSession) { + // Not found, lets reset. + deleteSessionKey(); + setSessionID(undefined); + setSessionState(SessionState.NoAuth); + setLock(false); + return; + } + + const newSession = { + user: serverSession.user, + guilds: serverSession.guilds, + }; + + saveSessionData({ sessionID: sessionID || '', session: newSession }); + setSession(newSession); + setSessionState(SessionState.FullAuth); + setLock(false); + } catch (e) { + console.error('syncSession failed', e); + setLock(false); } - - const serverSession: SessionContextT['session'] = await response.json(); - - setSession(serverSession); - sessionStorage.setItem('rp_session_data', JSON.stringify(serverSession)); }; syncSession(); } - }, [ - sessionContextValue.session?.user, - sessionContextValue.session?.sessionID, - sessionContextValue, - ]); + }, [sessionState, sessionID, authedFetch, lock]); return ( @@ -106,7 +176,28 @@ export const SessionContextProvider = (props: { children: React.ReactNode }) => ); }; -const clearSessionData = () => { - sessionStorage.removeItem('rp_session_data'); - localStorage.removeItem('rp_session_key'); +const saveSessionKey = (key: string) => localStorage.setItem('rp_session_key', key); +const deleteSessionKey = () => localStorage.removeItem('rp_session_key'); +const getSessionKey = () => localStorage.getItem('rp_session_key'); + +type ServerSession = Omit; +const fetchSession = async ( + authedFetch: SessionContextT['authedFetch'] +): Promise => { + const sessionResponse = await authedFetch('/get-session'); + if (sessionResponse.status !== 200) { + return null; + } + + const { sessionID, guilds, user }: ServerSession = await sessionResponse.json(); + return { + sessionID, + guilds, + user, + }; }; + +const saveSessionData = (data: SavedSession) => + sessionStorage.setItem('rp_session_data', JSON.stringify(data)); +const getSessionData = (): SavedSession | null => + JSON.parse(sessionStorage.getItem('rp_session_data') || 'null'); diff --git a/packages/web/src/pages/machinery/new-session.spec.tsx b/packages/web/src/pages/machinery/new-session.spec.tsx new file mode 100644 index 0000000..00d5c6c --- /dev/null +++ b/packages/web/src/pages/machinery/new-session.spec.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import { useSessionContext } from '../../contexts/session/SessionContext'; +import NewSession from './new-session'; + +const setupSessionMock = jest.fn(); +(useSessionContext as jest.Mock) = jest.fn(() => ({ + setupSession: setupSessionMock, + isAuthenticated: true, +})); + +const testSessionID = 'sessionid1234'; + +it('sets up the session', () => { + render(); + + expect(useSessionContext).toBeCalled(); + expect(setupSessionMock).toBeCalledWith('sessionid1234'); +}); + +it('redirects to the correct location when rp_postauth_redirect is set', async () => { + localStorage.setItem('rp_postauth_redirect', '/hello_world'); + render(); + + const bounceLink = screen.getByText("If you aren't redirected soon, click here."); + expect(bounceLink.getAttribute('href')).toBe('/hello_world'); +}); + +it('redirects to the correct location by default', async () => { + localStorage.setItem('rp_postauth_redirect', '/servers'); + render(); + + const bounceLink = screen.getByText("If you aren't redirected soon, click here."); + expect(bounceLink.getAttribute('href')).toBe('/servers'); +}); diff --git a/packages/web/src/pages/machinery/new-session.tsx b/packages/web/src/pages/machinery/new-session.tsx index 0e47224..4cc009e 100644 --- a/packages/web/src/pages/machinery/new-session.tsx +++ b/packages/web/src/pages/machinery/new-session.tsx @@ -5,23 +5,23 @@ import { useSessionContext } from '../../contexts/session/SessionContext'; import { Title } from '../../utils/metaTitle'; const NewSession = (props: { sessionID: string }) => { - const session = useSessionContext(); + const { setupSession, isAuthenticated } = useSessionContext(); const [postauthUrl, setPostauthUrl] = React.useState('/servers'); React.useEffect(() => { - const url = new URL(window.location.href); - const id = props.sessionID || url.searchParams.get('session_id'); - if (id) { - localStorage.setItem('rp_session_key', id); - // session.setSession({ sessionID: id }); - - const storedPostauthUrl = localStorage.getItem('rp_postauth_redirect'); - if (storedPostauthUrl) { - setPostauthUrl(storedPostauthUrl); - localStorage.removeItem('rp_postauth_redirect'); - } + const storedPostauthUrl = localStorage.getItem('rp_postauth_redirect'); + if (storedPostauthUrl) { + setPostauthUrl(storedPostauthUrl); + localStorage.removeItem('rp_postauth_redirect'); } - }, [setPostauthUrl, props.sessionID, session]); + }, [setPostauthUrl]); + + React.useCallback( + (sessionID) => { + setupSession(sessionID); + }, + [setupSession] + )(props.sessionID); return ( <> @@ -30,7 +30,7 @@ const NewSession = (props: { sessionID: string }) => {
If you aren't redirected soon, click here.
- {session.isAuthenticated && } + {isAuthenticated && } ); };