Skip to content

Commit

Permalink
Refactor otp/2fa modal and dataStore
Browse files Browse the repository at this point in the history
- Isolate the modals to their own file
- Create a hook to ensure changes to otp disk settings are picked up by
  the `NotificationService`
- Pull all logic for otp reminder notification state into `NotificationService`
  • Loading branch information
Jon-edge committed Feb 7, 2025
1 parent c297bfd commit f358f4b
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 91 deletions.
5 changes: 2 additions & 3 deletions src/components/notification/NotificationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { config } from '../../theme/appConfig'
import { useDispatch, useSelector } from '../../types/reactRedux'
import { NavigationBase } from '../../types/routerTypes'
import { getThemedIconUri } from '../../util/CdnUris'
import { getOtpReminderModal } from '../../util/otpReminder'
import { showOtpReminderModal } from '../../util/otpReminder'
import { openBrowserUri } from '../../util/WebUtils'
import { EdgeAnim, fadeIn, fadeOut } from '../common/EdgeAnim'
import { styled } from '../hoc/styled'
Expand Down Expand Up @@ -83,8 +83,7 @@ const NotificationViewComponent = (props: Props) => {
})
const handleOtpReminderPress = useHandler(async () => {
await handleOtpReminderClose()
const otpReminderModal = await getOtpReminderModal(account)
if (otpReminderModal != null) await otpReminderModal()
await showOtpReminderModal(account)
})

const handleLayout = useHandler((event: LayoutChangeEvent) => {
Expand Down
5 changes: 2 additions & 3 deletions src/components/scenes/NotificationCenterScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { config } from '../../theme/appConfig'
import { useDispatch, useSelector } from '../../types/reactRedux'
import { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes'
import { getThemedIconUri } from '../../util/CdnUris.ts'
import { getOtpReminderModal } from '../../util/otpReminder.tsx'
import { showOtpReminderModal } from '../../util/otpReminder.tsx'
import { openBrowserUri } from '../../util/WebUtils.ts'
import { SceneWrapper } from '../common/SceneWrapper'
import { SectionHeader } from '../common/SectionHeader'
Expand Down Expand Up @@ -43,8 +43,7 @@ export const NotificationCenterScene = (props: Props) => {
})

const handleOtpReminderPress = useHandler(async () => {
const otpReminderModal = await getOtpReminderModal(account)
if (otpReminderModal != null) await otpReminderModal()
await showOtpReminderModal(account)
})

const handle2FaEnabledPress = useHandler(async () => {
Expand Down
34 changes: 18 additions & 16 deletions src/components/services/NotificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import { EdgeAccount } from 'edge-core-js'

import { getLocalAccountSettings, useAccountSettings, writeAccountNotifInfo } from '../../actions/LocalSettingsActions'
import { useAsyncEffect } from '../../hooks/useAsyncEffect.ts'
import { useAsyncValue } from '../../hooks/useAsyncValue.ts'
import { useWatch } from '../../hooks/useWatch.ts'
import { useSelector } from '../../types/reactRedux.ts'
import { asNotifInfo } from '../../types/types.ts'
import { getOtpReminderModal } from '../../util/otpReminder.tsx'
import { OTP_REMINDER_MILLISECONDS, useOtpSettings } from '../../util/otpUtils.ts'

const PRIORITY_NOTIFICATION_KEYS = ['ip2FaReminder', 'lightAccountReminder', 'otpReminder', 'pwReminder']

Expand Down Expand Up @@ -61,23 +60,26 @@ export const updateNotificationInfo = async (
*/
export const NotificationService = (props: Props) => {
const { account } = props

const { notifState, accountNotifDismissInfo } = useAccountSettings()
const { lastChecked, dontAsk } = useOtpSettings()

const isLightAccountReminder = account.id != null && account.username == null
const detectedTokensRedux = useSelector(state => state.core.enabledDetectedTokens)
const isPwReminder = useSelector(state => state.ui.passwordReminder.needsPasswordCheck)
const wallets = useWatch(account, 'currencyWallets')
const otpKey = useWatch(account, 'otpKey')
const isIp2faReminder = !isLightAccountReminder && otpKey == null && accountNotifDismissInfo != null && !accountNotifDismissInfo.ip2FaNotifShown

const [isOtpReminderModal] = useAsyncValue(async () => {
try {
const otpReminderModal = await getOtpReminderModal(account)
return otpReminderModal != null
} catch (error) {
return false
}
}, [account])
const detectedTokensRedux = useSelector(state => state.core.enabledDetectedTokens)
const isPwReminder = useSelector(state => state.ui.passwordReminder.needsPasswordCheck)

const isLightAccountReminder = account.id != null && account.username == null

const isOtpReminder =
otpKey == null &&
!isLightAccountReminder &&
!dontAsk &&
((lastChecked == null && (account.created == null || Date.now() > account.created.valueOf() + OTP_REMINDER_MILLISECONDS)) ||
(lastChecked != null && Date.now() > lastChecked.valueOf() + OTP_REMINDER_MILLISECONDS))

const isIp2faReminder = !isLightAccountReminder && otpKey == null && accountNotifDismissInfo != null && !accountNotifDismissInfo.ip2FaNotifShown

// Update notification info with
// 1. Date last received if transitioning from incomplete to complete
Expand All @@ -98,13 +100,13 @@ export const NotificationService = (props: Props) => {

await updateNotificationInfo(account, 'ip2FaReminder', isIp2faReminder)
await updateNotificationInfo(account, 'lightAccountReminder', isLightAccountReminder)
await updateNotificationInfo(account, 'otpReminder', isOtpReminderModal ?? false)
await updateNotificationInfo(account, 'otpReminder', isOtpReminder ?? false)
await updateNotificationInfo(account, 'pwReminder', isPwReminder)

// cleanup:
return () => {}
},
[isIp2faReminder, isLightAccountReminder, isOtpReminderModal, isPwReminder, wallets, detectedTokensRedux, notifState],
[isIp2faReminder, isLightAccountReminder, isOtpReminder, isPwReminder, wallets, detectedTokensRedux, notifState],
'NotificationServices'
)

Expand Down
112 changes: 47 additions & 65 deletions src/util/otpReminder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,80 +6,62 @@ import { sprintf } from 'sprintf-js'
import { ButtonsModal } from '../components/modals/ButtonsModal'
import { Airship } from '../components/services/AirshipInstance'
import { lstrings } from '../locales/strings'

const OTP_REMINDER_MILLISECONDS = 7 * 24 * 60 * 60 * 1000
const OTP_REMINDER_STORE_NAME = 'app.edge.login'
const OTP_REMINDER_KEY_NAME_LAST_OTP_CHECKED = 'lastOtpCheck'
const OTP_REMINDER_KEY_NAME_DONT_ASK = 'OtpDontAsk'
import { OTP_REMINDER_MILLISECONDS, readOtpSettings, writeOtpSettings } from './otpUtils'

/**
* Check and return a potential 2fa reminder modal if needed to be shown onPress
* of its corresponding notification card.
* Return an otp reminder modal, with or without a "don't ask again" button,
* depending on if they've seen this before.
*/
export async function getOtpReminderModal(account: EdgeAccount): Promise<(() => Promise<void>) | undefined> {
const { otpKey, dataStore, username, created } = account
if (username == null || otpKey != null) return
const [dontAsk, lastOtpCheckString]: [string | null, string | null] = await Promise.all([
dataStore.getItem(OTP_REMINDER_STORE_NAME, OTP_REMINDER_KEY_NAME_DONT_ASK).catch(() => null),
dataStore.getItem(OTP_REMINDER_STORE_NAME, OTP_REMINDER_KEY_NAME_LAST_OTP_CHECKED).catch(() => null)
])
if (dontAsk) return
export async function showOtpReminderModal(account: EdgeAccount): Promise<void> {
const { created } = account
const { lastChecked } = await readOtpSettings(account)
Keyboard.dismiss()

// Return a modal if we have never shown it before, and the account is old
// enough:
const lastOtpCheck = lastOtpCheckString != null ? parseInt(lastOtpCheckString) : null
if (lastOtpCheck == null && (created == null || Date.now() > created.valueOf() + OTP_REMINDER_MILLISECONDS)) {
return async () => {
Keyboard.dismiss()
const result = await Airship.show<'yes' | 'no' | undefined>(bridge => (
<ButtonsModal
bridge={bridge}
title={lstrings.otp_reset_modal_header}
message={lstrings.otp_reset_modal_message}
buttons={{
yes: { label: lstrings.otp_enable },
no: { label: lstrings.skip, type: 'secondary' }
}}
/>
))
if (result === 'yes') {
await enableOtp(account)
} else {
await account.dataStore.setItem(OTP_REMINDER_STORE_NAME, OTP_REMINDER_KEY_NAME_LAST_OTP_CHECKED, Date.now().toString())
}
if (lastChecked == null && (created == null || Date.now() > created.valueOf() + OTP_REMINDER_MILLISECONDS)) {
const result = await Airship.show<'yes' | 'no' | undefined>(bridge => (
<ButtonsModal
bridge={bridge}
title={lstrings.otp_reset_modal_header}
message={lstrings.otp_reset_modal_message}
buttons={{
yes: { label: lstrings.otp_enable },
no: { label: lstrings.skip, type: 'secondary' }
}}
/>
))
if (result === 'yes') {
await enableOtp(account)
} else {
await writeOtpSettings(account, { lastChecked: new Date(Date.now()) })
}
}

// Return a modal with the "Don't ask again" button if we have waited long
// enough since the last time we showed a 2fa reminder modal:
if (lastOtpCheck != null && Date.now() > lastOtpCheck + OTP_REMINDER_MILLISECONDS) {
return async () => {
Keyboard.dismiss()
const result = await Airship.show<'enable' | 'cancel' | 'dontAsk' | undefined>(bridge => (
<ButtonsModal
bridge={bridge}
title={lstrings.otp_reset_modal_header}
message={lstrings.otp_reset_modal_message}
buttons={{
enable: { label: lstrings.otp_enable, type: 'primary' },
cancel: { label: lstrings.skip, type: 'secondary' },
dontAsk: {
label: lstrings.otp_reset_modal_dont_ask,
type: 'secondary'
}
}}
/>
))
if (result === 'enable') {
await enableOtp(account)
} else if (result === 'dontAsk') {
await account.dataStore.setItem(OTP_REMINDER_STORE_NAME, OTP_REMINDER_KEY_NAME_DONT_ASK, 'true')
} else {
await account.dataStore.setItem(OTP_REMINDER_STORE_NAME, OTP_REMINDER_KEY_NAME_LAST_OTP_CHECKED, Date.now().toString())
}
} else {
// Return a modal with the "Don't ask again" button if we showed the first
// modal already:
const result = await Airship.show<'enable' | 'cancel' | 'dontAsk' | undefined>(bridge => (
<ButtonsModal
bridge={bridge}
title={lstrings.otp_reset_modal_header}
message={lstrings.otp_reset_modal_message}
buttons={{
enable: { label: lstrings.otp_enable, type: 'primary' },
cancel: { label: lstrings.skip, type: 'secondary' },
dontAsk: {
label: lstrings.otp_reset_modal_dont_ask,
type: 'secondary'
}
}}
/>
))
if (result === 'enable') {
await enableOtp(account)
} else if (result === 'dontAsk') {
await writeOtpSettings(account, { dontAsk: true })
} else {
await writeOtpSettings(account, { lastChecked: new Date(Date.now()) })
}
}
return undefined
}

/**
Expand Down
75 changes: 75 additions & 0 deletions src/util/otpUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { asBoolean, asDate, asObject, asOptional } from 'cleaners'
import { EdgeAccount } from 'edge-core-js'
import React from 'react'
import { makeEvent } from 'yavent'

const OTP_REMINDER_STORE_NAME = 'app.edge.login'
const OTP_REMINDER_KEY_NAME_LAST_OTP_CHECKED = 'lastOtpCheck'
const OTP_REMINDER_KEY_NAME_DONT_ASK = 'OtpDontAsk'

export const OTP_REMINDER_MILLISECONDS = 7 * 24 * 60 * 60 * 1000

// Combined settings interface
export interface OtpSettings {
lastChecked: Date | null
dontAsk: boolean
}

// Cleaner for the settings
export const asOtpSettings = asObject({
lastChecked: asOptional(asDate, null),
dontAsk: asOptional(asBoolean, false)
})

// Local state management
let localOtpSettings: OtpSettings = {
lastChecked: null,
dontAsk: false
}

// Event emitter for settings changes
const [watchOtpSettings, emitOtpSettings] = makeEvent<OtpSettings>()

export const getOtpSettings = (): OtpSettings => localOtpSettings

// React hook for accessing OTP settings
export function useOtpSettings(): OtpSettings {
const [, setOtpSettings] = React.useState(getOtpSettings())
React.useEffect(() => watchOtpSettings(setOtpSettings), [])
return localOtpSettings
}

// Read settings from disk
export async function readOtpSettings(account: EdgeAccount): Promise<OtpSettings> {
const [lastCheckedStr, dontAskStr] = await Promise.all([
account.dataStore.getItem(OTP_REMINDER_STORE_NAME, OTP_REMINDER_KEY_NAME_LAST_OTP_CHECKED).catch(() => null),
account.dataStore.getItem(OTP_REMINDER_STORE_NAME, OTP_REMINDER_KEY_NAME_DONT_ASK).catch(() => null)
])

const settings: OtpSettings = {
lastChecked: lastCheckedStr != null ? new Date(parseInt(lastCheckedStr)) : null,
dontAsk: dontAskStr === 'true'
}

localOtpSettings = settings
return settings
}

// Write settings to disk
export async function writeOtpSettings(account: EdgeAccount, settings: Partial<OtpSettings>): Promise<void> {
const newSettings = { ...localOtpSettings, ...settings }

const promises: Array<Promise<void>> = []

if (settings.lastChecked !== undefined) {
promises.push(account.dataStore.setItem(OTP_REMINDER_STORE_NAME, OTP_REMINDER_KEY_NAME_LAST_OTP_CHECKED, settings.lastChecked?.toString() ?? '0'))
}

if (settings.dontAsk !== undefined) {
promises.push(account.dataStore.setItem(OTP_REMINDER_STORE_NAME, OTP_REMINDER_KEY_NAME_DONT_ASK, settings.dontAsk?.toString() ?? 'false'))
}

await Promise.all(promises)
localOtpSettings = newSettings
emitOtpSettings(newSettings)
}
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2175,9 +2175,9 @@
randombytes "^2.1.0"
text-encoding "0.7.0"

"@fioprotocol/fiosdk@git+https://github.com/jon-edge/fiosdk_typescript.git#92a0fb895b2ce57e5955cd30cb4b7fa2bcc66bf2":
"@fioprotocol/fiosdk@https://github.com/jon-edge/fiosdk_typescript.git#92a0fb895b2ce57e5955cd30cb4b7fa2bcc66bf2":
version "1.9.0"
resolved "git+https://github.com/jon-edge/fiosdk_typescript.git#92a0fb895b2ce57e5955cd30cb4b7fa2bcc66bf2"
resolved "https://github.com/jon-edge/fiosdk_typescript.git#92a0fb895b2ce57e5955cd30cb4b7fa2bcc66bf2"
dependencies:
"@fioprotocol/fiojs" "1.0.1"
"@types/text-encoding" "0.0.35"
Expand Down Expand Up @@ -10566,9 +10566,9 @@ eyes@^0.1.8:
resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0"
integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==

"eztz.js@git+https://github.com/EdgeApp/eztz.git#edge-fixes":
"eztz.js@https://github.com/EdgeApp/eztz.git#edge-fixes":
version "0.0.1"
resolved "git+https://github.com/EdgeApp/eztz.git#eefa603586810c3d62f852e7f28cfe57c523b7db"
resolved "https://github.com/EdgeApp/eztz.git#eefa603586810c3d62f852e7f28cfe57c523b7db"
dependencies:
bignumber.js "^7.2.1"
bip39 "^3.0.2"
Expand Down

0 comments on commit f358f4b

Please sign in to comment.