diff --git a/src/app/AppContainer.tsx b/src/app/AppContainer.tsx index 16feec6df..03036d3e5 100644 --- a/src/app/AppContainer.tsx +++ b/src/app/AppContainer.tsx @@ -16,10 +16,13 @@ import Routing from './Routing'; import styled from 'styled-components'; import { useOnMount } from '../utils/customHooks'; import VelgEnhet from './VelgEnhet'; -import usePersistentLogin from '../utils/hooks/use-persistent-login'; import LoggetUtModal from './LoggetUtModal'; import { useValgtenhet, ValgtEnhetProvider } from '../context/valgtenhet-state'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { usePersistentWWLogin } from '../login/use-persistent-ww-login'; +import usePersistentLogin from '../utils/hooks/use-persistent-login'; +import useFeatureToggle from '../components/featureToggle/useFeatureToggle'; +import { FeatureToggles } from '../components/featureToggle/toggleIDs'; const AppStyle = styled.div` height: 100vh; @@ -42,9 +45,16 @@ const ContentStyle = styled.div` const store = createStore(reducers, composeWithDevTools(applyMiddleware(thunk))); function App() { - const loginState = usePersistentLogin(); + const loginStateOld = usePersistentLogin(); + const loginStateNew = usePersistentWWLogin(); + const { isOn: newLoginStateToggleIsOn } = useFeatureToggle(FeatureToggles.BrukWebworkerPaaInnLogging); const valgtEnhet = useValgtenhet().enhetId; + let loginState = loginStateOld; + if (newLoginStateToggleIsOn) { + loginState = loginStateNew; + } + if (!valgtEnhet) { /** * valgt enhet hentes fra modiacontextholder, og mellomlagres i localStorage diff --git a/src/components/featureToggle/toggleIDs.ts b/src/components/featureToggle/toggleIDs.ts index c72d9f723..1e893c700 100644 --- a/src/components/featureToggle/toggleIDs.ts +++ b/src/components/featureToggle/toggleIDs.ts @@ -1,4 +1,5 @@ export enum FeatureToggles { BrukSoknadsstatus = 'modiapersonoversikt.soknadsstatus-api', + BrukWebworkerPaaInnLogging = 'modiapersonoversikt.web-worker-paa-innlogging', DebugMeldingsFunksjonalitet = 'modiapersonoversikt.ny-send-melding-container' } diff --git a/src/login/AcitivityMonitor.ts b/src/login/AcitivityMonitor.ts new file mode 100644 index 000000000..46177cc67 --- /dev/null +++ b/src/login/AcitivityMonitor.ts @@ -0,0 +1,11 @@ +export class ActivityMonitor { + private lastActivity: number = new Date().getTime(); + + public update() { + this.lastActivity = new Date().getTime(); + } + + public timeSinceLastActivity(): number { + return new Date().getTime() - this.lastActivity; + } +} diff --git a/src/login/LoginStateManager.ts b/src/login/LoginStateManager.ts new file mode 100644 index 000000000..da014a34d --- /dev/null +++ b/src/login/LoginStateManager.ts @@ -0,0 +1,100 @@ +import { AuthIntropectionDTO } from '../utils/hooks/use-persistent-login'; +import { ActivityMonitor } from './AcitivityMonitor'; +import { INACTIVITY_LIMIT_IN_MS, PREEMPTIVE_REFRESH_TIME_IN_MS, RECALC_LOGIN_STATUS_INTERVAL_IN_MS } from './constants'; +import { timeToExpiration } from './timeToExpiration'; + +export class LoginStateManager { + private timeout: ReturnType | null = null; + private interval: ReturnType | null = null; + private activityMonitor = new ActivityMonitor(); + private _refreshToken?: () => void; + private _onLoginStateUpdate?: (props: { isLoggedIn: boolean }) => void; + + initialize = (refreshToken: () => void, onLoginStateUpdate: (props: { isLoggedIn: boolean }) => void) => { + this._refreshToken = refreshToken; + this._onLoginStateUpdate = onLoginStateUpdate; + }; + + private setupTokenRefresher = (timeToRefresh: number) => { + this.timeout = this.getTokenRefreshTimeout(this.activityMonitor, timeToRefresh); + }; + + private get refreshToken() { + if (!this._refreshToken) { + throw new Error('[LoginStateManager] var ikke initialisert med en metode for å refreshe token'); + } + return this._refreshToken; + } + + private get onLoginStateUpdate() { + if (!this._onLoginStateUpdate) { + throw new Error('[LoginStateManager] var ikke initialisert med en metode for å sende login oppdateringer'); + } + return this._onLoginStateUpdate; + } + + private getTokenRefreshTimeout = (activityMonitor: ActivityMonitor, timeToExpiration: number) => { + return setTimeout(() => { + if (activityMonitor.timeSinceLastActivity() < INACTIVITY_LIMIT_IN_MS) { + if (!this.refreshToken) { + throw new Error('[LoginStateManager] Var ikke initialisert med '); + } + if (this.refreshToken) { + this.refreshToken(); + } + } + }, [timeToExpiration - PREEMPTIVE_REFRESH_TIME_IN_MS]); + }; + + private getLoginStateInterval = (auth: AuthIntropectionDTO) => { + return setInterval(() => { + const timeLeft = timeToExpiration(auth.expirationDate); + if (this.onLoginStateUpdate) { + this.onLoginStateUpdate({ isLoggedIn: timeLeft > 0 }); + } + }, RECALC_LOGIN_STATUS_INTERVAL_IN_MS); + }; + + private onAuthStateUpdate = (auth: AuthIntropectionDTO) => { + this.stopTokenRefresher(); + const timeToRefresh = timeToExpiration(auth.expirationDate); + if (timeToRefresh === 0) { + if (this.refreshToken) { + this.refreshToken(); + } + return; + } + this.setupTokenRefresher(timeToRefresh); + }; + + private setupLoginStateNotifier = (auth: AuthIntropectionDTO) => { + this.stopLoginStateNotifier(); + this.interval = this.getLoginStateInterval(auth); + }; + + private stopTokenRefresher = () => { + if (this.timeout) { + clearTimeout(this.timeout); + } + }; + + private stopLoginStateNotifier = () => { + if (this.interval) { + clearInterval(this.interval); + } + }; + + onUserActive = () => { + this.activityMonitor.update(); + }; + + onUpdate = (auth: AuthIntropectionDTO) => { + this.onAuthStateUpdate(auth); + this.setupLoginStateNotifier(auth); + }; + + stopWork = () => { + this.stopTokenRefresher(); + this.stopLoginStateNotifier(); + }; +} diff --git a/src/login/WebWorkerCommunicator.ts b/src/login/WebWorkerCommunicator.ts new file mode 100644 index 000000000..a15aea252 --- /dev/null +++ b/src/login/WebWorkerCommunicator.ts @@ -0,0 +1,87 @@ +import { AuthIntropectionDTO } from '../utils/hooks/use-persistent-login'; +import { LoginStateManager } from './LoginStateManager'; +import { WWMessage, OutgoingMessageType, IncommingMessageType } from './types'; + +export interface IWebWorkerCom { + initialize: (refreshToken: () => void, onLoginStateUpdate: (props: { isLoggedIn: boolean }) => void) => void; + onAuthChange: (newState: AuthIntropectionDTO) => void; + stop: () => void; + onUserActive: () => void; +} + +export class WebWorkerCommunicator implements IWebWorkerCom { + private worker: Worker; + refreshToken?: () => void; + onLoginStateUpdate?: (props: { isLoggedIn: boolean }) => void; + + constructor(worker: Worker) { + this.worker = worker; + } + + initialize = (refreshToken: () => void, onLoginStateUpdate: (props: { isLoggedIn: boolean }) => void) => { + this.refreshToken = refreshToken; + this.onLoginStateUpdate = onLoginStateUpdate; + this.worker.onmessage = (message: MessageEvent>) => { + const { type, payload } = message.data; + this.onMessage(type as OutgoingMessageType, payload); + }; + }; + + private sendMessage = (type: IncommingMessageType, payload?: any) => { + const message: WWMessage = { + type, + payload + }; + this.worker.postMessage(message); + }; + + private onMessage = (type: OutgoingMessageType, payload?: any) => { + switch (type) { + case 'REFRESH_TOKEN': { + if (!this.refreshToken) { + throw new Error('WebWorker was not initialized before being called'); + } + console.log(new Date().valueOf(), 'Refresh token message'); + this.refreshToken(); + return; + } + case 'LOGIN_STATE_UPDATE': { + if (!this.onLoginStateUpdate) { + throw new Error('WebWorker was not initialized before being called'); + } + console.log(new Date().valueOf(), 'Login state update'); + this.onLoginStateUpdate({ isLoggedIn: payload }); + return; + } + } + }; + + onUserActive = () => { + this.sendMessage('USER_ACTIVE'); + }; + + onAuthChange = (newState: AuthIntropectionDTO) => { + this.sendMessage('AUTH_STATE_UPDATE', newState); + }; + + stop = () => this.sendMessage('STOP_WORKER'); +} + +export class NoWorkerCommunicator implements IWebWorkerCom { + private loginStateManager = new LoginStateManager(); + + initialize = (refreshToken: () => void, onLoginStateUpdate: (props: { isLoggedIn: boolean }) => void) => { + this.loginStateManager.initialize(refreshToken, onLoginStateUpdate); + }; + + onAuthChange = (newState: AuthIntropectionDTO) => { + this.loginStateManager.onUpdate(newState); + }; + stop = () => { + this.loginStateManager.stopWork(); + }; + + onUserActive = () => { + this.loginStateManager.onUserActive(); + }; +} diff --git a/src/login/constants.ts b/src/login/constants.ts new file mode 100644 index 000000000..6ec659ef5 --- /dev/null +++ b/src/login/constants.ts @@ -0,0 +1,8 @@ +export const SECOND_IN_MS = 1000; +export const MINUTE_IN_MS = 60 * SECOND_IN_MS; + +export const RECALC_LOGIN_STATUS_INTERVAL_IN_MS = 30 * SECOND_IN_MS; +export const INACTIVITY_LIMIT_IN_MS = 10 * MINUTE_IN_MS; +export const PREEMPTIVE_REFRESH_TIME_IN_MS = 120 * SECOND_IN_MS; +export const ESTIMATED_EXPIRATION_IN_MS = 3600 * SECOND_IN_MS; +export const INVALID_EXPIRATION_DATE = -1; diff --git a/src/login/persistentLoginWebWorkerFactory.ts b/src/login/persistentLoginWebWorkerFactory.ts new file mode 100644 index 000000000..a550bce37 --- /dev/null +++ b/src/login/persistentLoginWebWorkerFactory.ts @@ -0,0 +1,16 @@ +import { IWebWorkerCom, NoWorkerCommunicator, WebWorkerCommunicator } from './WebWorkerCommunicator'; + +export const persistentLoginWebworkerFactory = (): IWebWorkerCom => { + if (!Worker) { + console.warn('WebWorker er ikke støttet av nettleseren. Kjører innlogging logikk i hovedtråden.'); + return new NoWorkerCommunicator(); + } + let worker: Worker; + try { + worker = new Worker(new URL('../loginWebWorker', import.meta.url)); + return new WebWorkerCommunicator(worker); + } catch (e) { + console.log(e); + return new NoWorkerCommunicator(); + } +}; diff --git a/src/login/timeToExpiration.ts b/src/login/timeToExpiration.ts new file mode 100644 index 000000000..a8a8e3de9 --- /dev/null +++ b/src/login/timeToExpiration.ts @@ -0,0 +1,9 @@ +import { ESTIMATED_EXPIRATION_IN_MS, INVALID_EXPIRATION_DATE } from './constants'; + +export const timeToExpiration = (expirationDate: number): number => { + if (expirationDate === INVALID_EXPIRATION_DATE) { + return ESTIMATED_EXPIRATION_IN_MS; + } + const currentDate = new Date().getTime(); + return expirationDate - currentDate; +}; diff --git a/src/login/types.ts b/src/login/types.ts new file mode 100644 index 000000000..fcbf199f8 --- /dev/null +++ b/src/login/types.ts @@ -0,0 +1,6 @@ +export type OutgoingMessageType = 'LOGIN_STATE_UPDATE' | 'REFRESH_TOKEN'; +export type IncommingMessageType = 'AUTH_STATE_UPDATE' | 'STOP_WORKER' | 'USER_ACTIVE'; +export interface WWMessage { + type: IncommingMessageType | OutgoingMessageType; + payload: T; +} diff --git a/src/login/use-persistent-ww-login.ts b/src/login/use-persistent-ww-login.ts new file mode 100644 index 000000000..a57f0286f --- /dev/null +++ b/src/login/use-persistent-ww-login.ts @@ -0,0 +1,74 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + AuthIntropectionDTO, + ErrorReason, + INVALID_EXPIRATION_DATE, + PersistentLoginState +} from '../utils/hooks/use-persistent-login'; +import { UseQueryResult, useQuery } from '@tanstack/react-query'; +import { FetchError, get } from '../api/api'; +import { apiBaseUri } from '../api/config'; +import { persistentLoginWebworkerFactory } from './persistentLoginWebWorkerFactory'; + +const authResource = { + useFetch(): UseQueryResult { + return useQuery(['auth'], () => get(`${apiBaseUri}/tilgang/auth`)); + } +}; + +const errorHandling = (auth: UseQueryResult): ErrorReason | undefined => { + if (auth.isError) { + return ErrorReason.FETCH_ERROR; + } else if (auth.data && auth.data.expirationDate === INVALID_EXPIRATION_DATE) { + return ErrorReason.INVALID_EXPIRATION_DATE; + } + return undefined; +}; + +const useAuthStateLogin = (auth: UseQueryResult) => { + const [isLoggedIn, setIsLoggedIn] = useState(true); + const [webWorkerCom] = useState(() => { + const worker = persistentLoginWebworkerFactory(); + worker.initialize(auth.refetch, ({ isLoggedIn }) => setIsLoggedIn(isLoggedIn)); + return worker; + }); + + useEffect(() => { + if (webWorkerCom) { + document.addEventListener('mousemove', webWorkerCom.onUserActive); + document.addEventListener('keydown', webWorkerCom.onUserActive); + } + return () => { + if (webWorkerCom) { + document.removeEventListener('mousemove', webWorkerCom.onUserActive); + document.removeEventListener('keydown', webWorkerCom.onUserActive); + } + }; + }, [webWorkerCom]); + + useEffect(() => { + if (auth.status === 'success') { + webWorkerCom.onAuthChange(auth.data); + return; + } else if (auth.status === 'error') { + setIsLoggedIn(false); + webWorkerCom.stop(); + } + }, [auth, webWorkerCom]); + + return isLoggedIn; +}; + +export const usePersistentWWLogin = (): PersistentLoginState => { + const auth = authResource.useFetch(); + const errorStatus = errorHandling(auth); + const isLoggedIn = useAuthStateLogin(auth); + + return useMemo( + () => ({ + isLoggedIn, + errorStatus + }), + [isLoggedIn, errorStatus] + ); +}; diff --git a/src/loginWebWorker.ts b/src/loginWebWorker.ts new file mode 100644 index 000000000..7b961c6a0 --- /dev/null +++ b/src/loginWebWorker.ts @@ -0,0 +1,43 @@ +/* eslint-disable no-restricted-globals */ +import { LoginStateManager } from './login/LoginStateManager'; +import { IncommingMessageType, OutgoingMessageType } from './login/types'; + +const loginStateManager = new LoginStateManager(); + +const register = () => { + console.log('Bruker webworker for å kontrollere inlogging'); + self.addEventListener('message', handleEventMessage); +}; + +const handleEventMessage = (event: MessageEvent<{ type: IncommingMessageType; payload: any }>) => { + const { type, payload } = event.data; + switch (type) { + case 'STOP_WORKER': + loginStateManager.stopWork(); + console.log('[loginWebWorker] Mottok melding: ', type); + return; + case 'AUTH_STATE_UPDATE': + loginStateManager.onUpdate(payload); + console.log(`[loginWebWorker] Mottok melding: ${type}, med: ${payload.expirationDate}`); + return; + case 'USER_ACTIVE': + loginStateManager.onUserActive(); + return; + } +}; + +const sendRefreshMessage = () => { + sendMessage('REFRESH_TOKEN'); +}; + +const sendIsLoggedIn = ({ isLoggedIn }: { isLoggedIn: boolean }) => { + sendMessage('LOGIN_STATE_UPDATE', isLoggedIn); +}; + +const sendMessage = (type: OutgoingMessageType, payload?: T) => { + self.postMessage({ type, payload }); +}; + +loginStateManager.initialize(sendRefreshMessage, sendIsLoggedIn); + +register(); diff --git a/src/setupTests.ts b/src/setupTests.ts index ddf3d4ab8..aa399e934 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -7,6 +7,7 @@ import 'dayjs/locale/nb'; import 'jest-enzyme'; import 'jest-styled-components'; import './extra-polyfills'; +import { IWebWorkerCom } from './login/WebWorkerCommunicator'; dayjs.locale('nb'); configure({ adapter: new EnzymeReactAdapter17() }); @@ -31,6 +32,8 @@ window.matchMedia = (query: string) => { return querylist as unknown as MediaQueryList; }; +global['Worker'] = undefined; + // Mock react collapse sin UnmountClosed jest.mock('react-collapse', () => { return { @@ -39,5 +42,20 @@ jest.mock('react-collapse', () => { }; }); +/** + * Jest har ikke støtte for import.meta som må brukes for å kunne lage WebWorker med Webpack p.t. + * I framtiden burde man fjerne denne mocken når man skriver seg bort fra legacy pakker. + */ + +const workerMock: IWebWorkerCom = { + initialize: () => null, + onAuthChange: () => null, + onUserActive: () => null, + stop: () => null +}; +jest.mock('./login/persistentLoginWebWorkerFactory.ts', () => ({ + persistentLoginWebworkerFactory: () => workerMock +})); + beforeEach(EnzymeContainer.beforeEachHandler); afterEach(EnzymeContainer.afterEachHandler);