refactor: repo (#1070)
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||
import {
|
||||
ButtonType,
|
||||
Challenge,
|
||||
ChallengePrompt,
|
||||
ChallengeReason,
|
||||
ChallengeValue,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs'
|
||||
import { ProtectedIllustration } from '@standardnotes/icons'
|
||||
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import ChallengeModalPrompt from './ChallengePrompt'
|
||||
import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher'
|
||||
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { ChallengeModalValues } from './ChallengeModalValues'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
viewControllerManager: ViewControllerManager
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
challenge: Challenge
|
||||
onDismiss?: (challenge: Challenge) => void
|
||||
}
|
||||
|
||||
const validateValues = (values: ChallengeModalValues, prompts: ChallengePrompt[]): ChallengeModalValues | undefined => {
|
||||
let hasInvalidValues = false
|
||||
const validatedValues = { ...values }
|
||||
for (const prompt of prompts) {
|
||||
const value = validatedValues[prompt.id]
|
||||
if (typeof value.value === 'string' && value.value.length === 0) {
|
||||
validatedValues[prompt.id].invalid = true
|
||||
hasInvalidValues = true
|
||||
}
|
||||
}
|
||||
if (!hasInvalidValues) {
|
||||
return validatedValues
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const ChallengeModal: FunctionComponent<Props> = ({
|
||||
application,
|
||||
viewControllerManager,
|
||||
mainApplicationGroup,
|
||||
challenge,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const [values, setValues] = useState<ChallengeModalValues>(() => {
|
||||
const values = {} as ChallengeModalValues
|
||||
for (const prompt of challenge.prompts) {
|
||||
values[prompt.id] = {
|
||||
prompt,
|
||||
value: prompt.initialValue ?? '',
|
||||
invalid: false,
|
||||
}
|
||||
}
|
||||
return values
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
|
||||
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
|
||||
const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes(
|
||||
challenge.reason,
|
||||
)
|
||||
const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const validatedValues = validateValues(values, challenge.prompts)
|
||||
if (!validatedValues) {
|
||||
return
|
||||
}
|
||||
if (isSubmitting || isProcessing) {
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
setIsProcessing(true)
|
||||
|
||||
const valuesToProcess: ChallengeValue[] = []
|
||||
for (const inputValue of Object.values(validatedValues)) {
|
||||
const rawValue = inputValue.value
|
||||
const value = { prompt: inputValue.prompt, value: rawValue }
|
||||
valuesToProcess.push(value)
|
||||
}
|
||||
|
||||
const processingPrompts = valuesToProcess.map((v) => v.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
setProcessingPrompts(processingPrompts)
|
||||
/**
|
||||
* Unfortunately neccessary to wait 50ms so that the above setState call completely
|
||||
* updates the UI to change processing state, before we enter into UI blocking operation
|
||||
* (crypto key generation)
|
||||
*/
|
||||
setTimeout(() => {
|
||||
if (valuesToProcess.length > 0) {
|
||||
application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
|
||||
} else {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}, 50)
|
||||
}, [application, challenge, isProcessing, isSubmitting, values])
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(value: string | number, prompt: ChallengePrompt) => {
|
||||
const newValues = { ...values }
|
||||
newValues[prompt.id].invalid = false
|
||||
newValues[prompt.id].value = value
|
||||
setValues(newValues)
|
||||
},
|
||||
[values],
|
||||
)
|
||||
|
||||
const cancelChallenge = useCallback(() => {
|
||||
if (challenge.cancelable) {
|
||||
application.cancelChallenge(challenge)
|
||||
onDismiss?.(challenge)
|
||||
}
|
||||
}, [application, challenge, onDismiss])
|
||||
|
||||
useEffect(() => {
|
||||
const removeChallengeObserver = application.addChallengeObserver(challenge, {
|
||||
onValidValue: (value) => {
|
||||
setValues((values) => {
|
||||
const newValues = { ...values }
|
||||
newValues[value.prompt.id].invalid = false
|
||||
return newValues
|
||||
})
|
||||
setProcessingPrompts((currentlyProcessingPrompts) => {
|
||||
const processingPrompts = currentlyProcessingPrompts.slice()
|
||||
removeFromArray(processingPrompts, value.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
return processingPrompts
|
||||
})
|
||||
},
|
||||
onInvalidValue: (value) => {
|
||||
setValues((values) => {
|
||||
const newValues = { ...values }
|
||||
newValues[value.prompt.id].invalid = true
|
||||
return newValues
|
||||
})
|
||||
/** If custom validation, treat all values together and not individually */
|
||||
if (!value.prompt.validates) {
|
||||
setProcessingPrompts([])
|
||||
setIsProcessing(false)
|
||||
} else {
|
||||
setProcessingPrompts((currentlyProcessingPrompts) => {
|
||||
const processingPrompts = currentlyProcessingPrompts.slice()
|
||||
removeFromArray(processingPrompts, value.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
return processingPrompts
|
||||
})
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
onDismiss?.(challenge)
|
||||
},
|
||||
onCancel: () => {
|
||||
onDismiss?.(challenge)
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeChallengeObserver()
|
||||
}
|
||||
}, [application, challenge, onDismiss])
|
||||
|
||||
if (!challenge.prompts) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogOverlay
|
||||
className={`sn-component ${
|
||||
challenge.reason === ChallengeReason.ApplicationUnlock ? 'challenge-modal-overlay' : ''
|
||||
}`}
|
||||
onDismiss={cancelChallenge}
|
||||
dangerouslyBypassFocusLock={bypassModalFocusLock}
|
||||
key={challenge.id}
|
||||
>
|
||||
<DialogContent
|
||||
aria-label="Challenge modal"
|
||||
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
|
||||
challenge.reason !== ChallengeReason.ApplicationUnlock
|
||||
? 'shadow-overlay-light border-1 border-solid border-main'
|
||||
: 'focus:shadow-none'
|
||||
}`}
|
||||
>
|
||||
{challenge.cancelable && (
|
||||
<button
|
||||
onClick={cancelChallenge}
|
||||
aria-label="Close modal"
|
||||
className="flex p-1 bg-transparent border-0 cursor-pointer absolute top-4 right-4"
|
||||
>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
</button>
|
||||
)}
|
||||
<ProtectedIllustration className="w-30 h-30 mb-4" />
|
||||
<div className="font-bold text-lg text-center max-w-76 mb-3">{challenge.heading}</div>
|
||||
|
||||
{challenge.subheading && (
|
||||
<div className="text-center text-sm max-w-76 mb-4 break-word">{challenge.subheading}</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="flex flex-col items-center min-w-76"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
{challenge.prompts.map((prompt, index) => (
|
||||
<ChallengeModalPrompt
|
||||
key={prompt.id}
|
||||
prompt={prompt}
|
||||
values={values}
|
||||
index={index}
|
||||
onValueChange={onValueChange}
|
||||
isInvalid={values[prompt.id].invalid}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
<Button variant="primary" disabled={isProcessing} className="min-w-76 mt-1 mb-3.5" onClick={submit}>
|
||||
{isProcessing ? 'Generating Keys...' : 'Submit'}
|
||||
</Button>
|
||||
{shouldShowForgotPasscode && (
|
||||
<Button
|
||||
className="flex items-center justify-center min-w-76"
|
||||
onClick={() => {
|
||||
setBypassModalFocusLock(true)
|
||||
application.alertService
|
||||
.confirm(
|
||||
'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.',
|
||||
'Forgot passcode?',
|
||||
'Delete local data',
|
||||
ButtonType.Danger,
|
||||
)
|
||||
.then((shouldDeleteLocalData) => {
|
||||
if (shouldDeleteLocalData) {
|
||||
application.user.signOut().catch(console.error)
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
setBypassModalFocusLock(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon type="help" className="mr-2 color-neutral" />
|
||||
Forgot passcode?
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowWorkspaceSwitcher && (
|
||||
<LockscreenWorkspaceSwitcher
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
viewControllerManager={viewControllerManager}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChallengeModal
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ChallengePrompt } from '@standardnotes/snjs'
|
||||
import { InputValue } from './InputValue'
|
||||
|
||||
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useEffect, useRef } from 'react'
|
||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
|
||||
import { ChallengeModalValues } from './ChallengeModalValues'
|
||||
|
||||
type Props = {
|
||||
prompt: ChallengePrompt
|
||||
values: ChallengeModalValues
|
||||
index: number
|
||||
onValueChange: (value: string | number, prompt: ChallengePrompt) => void
|
||||
isInvalid: boolean
|
||||
}
|
||||
|
||||
const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (index === 0) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [index])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInvalid) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isInvalid])
|
||||
|
||||
return (
|
||||
<div key={prompt.id} className="w-full mb-3">
|
||||
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
||||
<div className="min-w-76">
|
||||
<div className="text-sm font-medium mb-2">Allow protected access for</div>
|
||||
<div className="flex items-center justify-between bg-passive-4 rounded p-1">
|
||||
{ProtectionSessionDurations.map((option) => {
|
||||
const selected = option.valueInSeconds === values[prompt.id].value
|
||||
return (
|
||||
<label
|
||||
key={option.label}
|
||||
className={`cursor-pointer px-2 py-1.5 rounded ${
|
||||
selected ? 'bg-default color-foreground font-semibold' : 'color-passive-0 hover:bg-passive-3'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`session-duration-${prompt.id}`}
|
||||
className={'appearance-none m-0 focus:shadow-none focus:outline-none'}
|
||||
style={{
|
||||
marginRight: 0,
|
||||
}}
|
||||
checked={selected}
|
||||
onChange={(event) => {
|
||||
event.preventDefault()
|
||||
onValueChange(option.valueInSeconds, prompt)
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : prompt.secureTextEntry ? (
|
||||
<DecoratedPasswordInput
|
||||
ref={inputRef}
|
||||
placeholder={prompt.placeholder}
|
||||
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
|
||||
onChange={(value) => onValueChange(value, prompt)}
|
||||
/>
|
||||
) : (
|
||||
<DecoratedInput
|
||||
ref={inputRef}
|
||||
placeholder={prompt.placeholder}
|
||||
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
|
||||
onChange={(value) => onValueChange(value, prompt)}
|
||||
/>
|
||||
)}
|
||||
{isInvalid && <div className="text-sm color-danger mt-2">Invalid authentication, please try again.</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChallengeModalPrompt
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ChallengePrompt } from '@standardnotes/snjs'
|
||||
|
||||
export type InputValue = {
|
||||
prompt: ChallengePrompt
|
||||
value: string | number | boolean
|
||||
invalid: boolean
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import WorkspaceSwitcherMenu from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
viewControllerManager: ViewControllerManager
|
||||
}
|
||||
|
||||
const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
|
||||
|
||||
useCloseOnClickOutside(containerRef, () => setIsOpen(false))
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
if (!isOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(buttonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen)
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const timeToWaitBeforeCheckingMenuCollision = 0
|
||||
setTimeout(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
}, timeToWaitBeforeCheckingMenuCollision)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Button ref={buttonRef} onClick={toggleMenu} className="flex items-center justify-center min-w-76 mt-2">
|
||||
<Icon type="user-switch" className="color-neutral mr-2" />
|
||||
Switch workspace
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
|
||||
<WorkspaceSwitcherMenu
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
viewControllerManager={viewControllerManager}
|
||||
isOpen={isOpen}
|
||||
hideWorkspaceOptions={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LockscreenWorkspaceSwitcher
|
||||
Reference in New Issue
Block a user