Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,11 @@ module.exports = {
rules: {
'react/no-unknown-property': [2, { ignore: ['jsx'] }],
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
}
4 changes: 4 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: { [key: string]: string }
export default classes
}
33 changes: 31 additions & 2 deletions i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"POT-Creation-Date: 2025-05-21T12:31:28.314Z\n"
"PO-Revision-Date: 2025-05-21T12:31:28.314Z\n"
"POT-Creation-Date: 2026-03-01T17:24:29.464Z\n"
"PO-Revision-Date: 2026-03-01T17:24:29.464Z\n"

msgid "Save your data"
msgstr "Save your data"
Expand Down Expand Up @@ -152,3 +152,32 @@ msgstr "App updates available — Click to reload"

msgid "Profile menu"
msgstr "Profile menu"

msgid "Session ending"
msgstr "Session ending"

msgid ""
"For security, you will be logged out after {{sessionTimeoutInMinutes}} "
"minutes without any network activity."
msgstr ""
"For security, you will be logged out after {{sessionTimeoutInMinutes}} "
"minutes without any network activity."

msgid "Stay logged in"
msgstr "Stay logged in"

msgid "You have been logged out"
msgstr "You have been logged out"

msgid ""
"Your session ended after {{sessionTimeoutInMinutes}} minutes without "
"network activity. Log in again to continue."
msgstr ""
"Your session ended after {{sessionTimeoutInMinutes}} minutes without "
"network activity. Log in again to continue."

msgid "Dismiss"
msgstr "Dismiss"

msgid "Log in"
msgstr "Log in"
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@
"typescript": "^5.7.3"
},
"dependencies": {
"@dhis2/app-runtime": "^3.14.0",
"@dhis2/app-runtime": "^3.16.0",
"@dhis2/pwa": "^12.3.0",
"@dhis2/ui": "^10.1.13",
"@types/js-cookie": "^3.0.6",
"js-cookie": "^3.0.5",
"apca-w3": "^0.1.9",
"react-router": "^7.2.0",
"styled-jsx": "^4.0.1"
Expand Down
18 changes: 18 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import styles from './App.module.css'
import { ConnectedHeaderBar } from './components/ConnectedHeaderbar.jsx'
import { PluginLoader } from './components/PluginLoader.jsx'
import { RedirectHandler } from './components/RedirectHandler.tsx'
import {
SessionHandler,
getSessionCookie,
} from './components/session-handler/index.ts'
import { ClientPWAProvider } from './lib/clientPWAUpdateState.jsx'

const APPS_INFO_QUERY = {
Expand All @@ -27,8 +31,22 @@ const APPS_INFO_QUERY = {
}

const Layout = ({ appsInfoQuery }) => {
const sessionCookie = getSessionCookie()

const { systemInfo } = useConfig()

const supportsSessionHandler =
sessionCookie?.sessionExpiryTime &&
sessionCookie?.serverTime &&
systemInfo?.sessionTimeout

return (
<div className={styles.container}>
{supportsSessionHandler && (
<SessionHandler
sessionTimeoutInSeconds={systemInfo?.sessionTimeout}
/>
)}
<ConnectedHeaderBar appsInfoQuery={appsInfoQuery} />
{/* Skip the routes in dev; they don't make the same sense */}
{process.env.NODE_ENV !== 'development' ? <Outlet /> : null}
Expand Down
8 changes: 8 additions & 0 deletions src/components/session-handler/helpers/calculate-skew.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const calculateSkew = (serverTime: number) => {
const clientTime = Date.now()
const clientDiffWithServer = clientTime - serverTime

return clientDiffWithServer
}

export default calculateSkew
16 changes: 16 additions & 0 deletions src/components/session-handler/helpers/format-countdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
type FormatCountDownFn = (countdownInSeconds: number) => {
minutes: string
seconds: string
}

const formatCountdown: FormatCountDownFn = (countdownInSeconds) => {
const minutes = Math.floor(countdownInSeconds / 60)
const seconds = countdownInSeconds - minutes * 60

return {
minutes: String(minutes).padStart(2, '0'),
seconds: String(seconds).padStart(2, '0'),
}
}

export default formatCountdown
36 changes: 36 additions & 0 deletions src/components/session-handler/helpers/get-session-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Cookies from 'js-cookie'

const SESSION_EXPIRY_COOKIE_NAME = 'SESSION_EXPIRE'

/**
* This reads the cookie, splits and returns the information saved in it (serverTime and sessionTimeout)
*
* The cookie format is server_time=%s&expiry_time=%s
* Both server-time and session-expiry are in seconds and need to be converted to milliseconds for use for JS Date

* @returns {sessionTimeout: number, serverTime: number}
*/
type GetSessionCookieFn = () => {
serverTime?: number
sessionExpiryTime?: number
} | null

const getSessionCookie: GetSessionCookieFn = () => {
const cookieValue = Cookies.get(SESSION_EXPIRY_COOKIE_NAME) ?? {}

const params = new URLSearchParams(cookieValue)

const serverTime = Number(params.get('server_time')) * 1000
const sessionExpiryTime = Number(params.get('expiry_time')) * 1000

if (isNaN(serverTime) || isNaN(sessionExpiryTime)) {
return null
}

return {
sessionExpiryTime,
serverTime,
}
}

export default getSessionCookie
3 changes: 3 additions & 0 deletions src/components/session-handler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import getSessionCookie from './helpers/get-session-cookie'
export { SessionHandler } from './session-handler'
export { getSessionCookie }
51 changes: 51 additions & 0 deletions src/components/session-handler/modal-countdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import i18n from '@dhis2/d2-i18n'
import {
Button,
ButtonStrip,
Modal,
ModalActions,
ModalContent,
ModalTitle,
} from '@dhis2/ui'
import * as React from 'react'
import formatCountdown from './helpers/format-countdown'
import styles from './session-handler.module.css'

type ExpirationCountdownModalProps = {
countDown: number
loading: boolean
onExtendSession: () => void
sessionTimeout: number
}

export const ExpirationCountdownModal: React.FC<
ExpirationCountdownModalProps
> = ({ countDown, onExtendSession, loading, sessionTimeout }) => {
const sessionTimeoutInMinutes = Math.floor(sessionTimeout / 60)

const { minutes, seconds } = formatCountdown(countDown)

return (
<Modal>
<ModalTitle>{i18n.t('Session ending')}</ModalTitle>
{/* ToDO: maybe makes sense to format the countDown in minutes / seconds? */}
<ModalContent>
{i18n.t(
'For security, you will be logged out after {{sessionTimeoutInMinutes}} minutes without any network activity.',
{ sessionTimeoutInMinutes }
)}

<div className={styles.timer}>{`${minutes}:${seconds}`}</div>
</ModalContent>
<ModalActions>
<ButtonStrip end>
{/* can this get into undesired state when offline - i.e. can't extend but want to dismiss */}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question... You're right that this would come up without connection to the server

The "X" button at the corner of the modal would still be there, but maybe The "Dismiss" button is okay? cc @cooper-joe

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the X isn't there currently, actually - that might be a prop on the modal

<Button primary onClick={onExtendSession} loading={loading}>
{i18n.t('Stay logged in')}
</Button>
{/* <Button>Dismiss</Button> */}
</ButtonStrip>
</ModalActions>
</Modal>
)
}
53 changes: 53 additions & 0 deletions src/components/session-handler/modal-expired.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useConfig } from '@dhis2/app-runtime'
import i18n from '@dhis2/d2-i18n'
import {
Button,
ButtonStrip,
IconLaunch16,
Modal,
ModalActions,
ModalContent,
ModalTitle,
} from '@dhis2/ui'
import * as React from 'react'

type ExpiredModalProps = {
dismissModal: () => void
sessionTimeout: number
}

export const ExpiredModal: React.FC<ExpiredModalProps> = ({
dismissModal,
sessionTimeout,
}) => {
const { baseUrl } = useConfig()

const sessionTimeoutInMinutes = Math.floor(sessionTimeout / 60)

const goToLogin = () => {
window.open(baseUrl, '_blank', 'noopener,noreferrer')
}
const dismiss = () => {
dismissModal()
}
return (
<Modal>
<ModalTitle>{i18n.t('You have been logged out')}</ModalTitle>
<ModalContent>
{i18n.t(
'Your session ended after {{sessionTimeoutInMinutes}} minutes without network activity. Log in again to continue.',
{ sessionTimeoutInMinutes }
)}
</ModalContent>
<ModalActions>
<ButtonStrip end>
<Button onClick={dismiss}>{i18n.t('Dismiss')}</Button>

<Button primary onClick={goToLogin} icon={<IconLaunch16 />}>
{i18n.t('Log in')}
</Button>
</ButtonStrip>
</ModalActions>
</Modal>
)
}
8 changes: 8 additions & 0 deletions src/components/session-handler/session-handler.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.timer {
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas,
Liberation Mono, monospace;
text-align: center;
font-weight: bold;
font-size: 24px;
margin-block: 16px;
}
66 changes: 66 additions & 0 deletions src/components/session-handler/session-handler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useDataQuery } from '@dhis2/app-runtime'
import * as React from 'react'
import { ExpirationCountdownModal } from './modal-countdown'
import { ExpiredModal } from './modal-expired'
import { useCheckCookie } from './use-check-cookie'

const query = {
user: {
resource: 'me',
params: {
fields: ['name'],
},
},
}

type SessionHandlerProps = {
sessionTimeoutInSeconds: number
}

export const SessionHandler: React.FC<SessionHandlerProps> = ({
sessionTimeoutInSeconds,
}) => {
const { showWarning, time, expired, reset } = useCheckCookie(
sessionTimeoutInSeconds
)
const [feedbackManuallyDismissed, setFeedbackManuallyDismissed] =
React.useState(false)
// const [received401, setReceived401] = React.useState(false)

const { refetch: extendSession, loading } = useDataQuery(query, {
lazy: true,
})

const onExtendSession = async () => {
Comment thread
kabaros marked this conversation as resolved.
await extendSession()
reset()
}

const dismissModal = async () => {
setFeedbackManuallyDismissed(true)
await extendSession()
reset()
}
Comment thread
kabaros marked this conversation as resolved.

if (!expired && showWarning) {
return (
<ExpirationCountdownModal
countDown={time!}
onExtendSession={onExtendSession}
loading={loading}
sessionTimeout={sessionTimeoutInSeconds}
/>
)
}

// ToDo: unsure - once dismissed, we don't show it again?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question... I think if you dismiss the "warning" modal, the "expired" modal should still pop up when it's time, ideally

Also, if the session expires at a later time, we should be able to show the modals... what if the useCheckCookie hook returned onTimeoutWarning and onExpired callbacks that could be used when those events happen? To do things like setWarningModalOpen(true), etc

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a Dismiss for the warning right now - should we? would they want to dismiss without extending?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe for the offline case? 🤔

if (expired && !feedbackManuallyDismissed) {
return (
<ExpiredModal
sessionTimeout={sessionTimeoutInSeconds}
dismissModal={dismissModal}
/>
)
}
return null
}
Loading
Loading