-
Notifications
You must be signed in to change notification settings - Fork 3
feat: handle session expiry gracefully #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4efd4ce
9644690
6a8c095
ac04fa9
94b53c3
a062b13
d57744d
1deaf98
e50f2ea
1234844
306dde8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } |
| 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 |
| 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 |
| 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 |
| 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 } |
| 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 */} | ||
| <Button primary onClick={onExtendSession} loading={loading}> | ||
| {i18n.t('Stay logged in')} | ||
| </Button> | ||
| {/* <Button>Dismiss</Button> */} | ||
| </ButtonStrip> | ||
| </ModalActions> | ||
| </Modal> | ||
| ) | ||
| } | ||
| 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> | ||
| ) | ||
| } |
| 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; | ||
| } |
| 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 () => { | ||
|
kabaros marked this conversation as resolved.
|
||
| await extendSession() | ||
| reset() | ||
| } | ||
|
|
||
| const dismissModal = async () => { | ||
| setFeedbackManuallyDismissed(true) | ||
| await extendSession() | ||
| reset() | ||
| } | ||
|
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? | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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