feat(dev): add u2f ui for managing devices and signing in (#2182)
* feat: add u2f ui for managing devices and signing in * refactor: change unnecessary useState to derived constant * fix: modal refactor * fix(web): hide u2f under feature trunk * fix(web): jest setup --------- Co-authored-by: Aman Harwara <amanharwara@protonmail.com>
This commit is contained in:
@@ -180,13 +180,23 @@ const ChallengeModal: FunctionComponent<Props> = ({
|
||||
}, [application, challenge, onDismiss])
|
||||
|
||||
const biometricPrompt = challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.Biometric)
|
||||
const authenticatorPrompt = challenge.prompts.find(
|
||||
(prompt) => prompt.validation === ChallengeValidation.Authenticator,
|
||||
)
|
||||
const hasOnlyBiometricPrompt = challenge.prompts.length === 1 && !!biometricPrompt
|
||||
const wasBiometricInputSuccessful = biometricPrompt && !!values[biometricPrompt.id].value
|
||||
const hasOnlyAuthenticatorPrompt = challenge.prompts.length === 1 && !!authenticatorPrompt
|
||||
const wasBiometricInputSuccessful = !!biometricPrompt && !!values[biometricPrompt.id].value
|
||||
const wasAuthenticatorInputSuccessful = !!authenticatorPrompt && !!values[authenticatorPrompt.id].value
|
||||
const hasSecureTextPrompt = challenge.prompts.some((prompt) => prompt.secureTextEntry)
|
||||
const shouldShowSubmitButton = !(hasOnlyBiometricPrompt || hasOnlyAuthenticatorPrompt)
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoSubmit = hasOnlyBiometricPrompt && wasBiometricInputSuccessful
|
||||
const shouldAutoSubmit =
|
||||
(hasOnlyBiometricPrompt && wasBiometricInputSuccessful) ||
|
||||
(hasOnlyAuthenticatorPrompt && wasAuthenticatorInputSuccessful)
|
||||
|
||||
const shouldFocusSecureTextPrompt = hasSecureTextPrompt && wasBiometricInputSuccessful
|
||||
|
||||
if (shouldAutoSubmit) {
|
||||
submit()
|
||||
} else if (shouldFocusSecureTextPrompt) {
|
||||
@@ -195,7 +205,14 @@ const ChallengeModal: FunctionComponent<Props> = ({
|
||||
) as HTMLInputElement | null
|
||||
secureTextEntry?.focus()
|
||||
}
|
||||
}, [wasBiometricInputSuccessful, hasOnlyBiometricPrompt, submit, hasSecureTextPrompt])
|
||||
}, [
|
||||
wasBiometricInputSuccessful,
|
||||
hasOnlyBiometricPrompt,
|
||||
submit,
|
||||
hasSecureTextPrompt,
|
||||
hasOnlyAuthenticatorPrompt,
|
||||
wasAuthenticatorInputSuccessful,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = application.addAndroidBackHandlerEventListener(() => {
|
||||
@@ -289,12 +306,15 @@ const ChallengeModal: FunctionComponent<Props> = ({
|
||||
index={index}
|
||||
onValueChange={onValueChange}
|
||||
isInvalid={values[prompt.id].invalid}
|
||||
contextData={prompt.contextData}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
<Button primary disabled={isProcessing} className="mt-1 mb-3.5 min-w-76" onClick={submit}>
|
||||
{isProcessing ? 'Generating Keys...' : 'Submit'}
|
||||
</Button>
|
||||
{shouldShowSubmitButton && (
|
||||
<Button primary disabled={isProcessing} className="mt-1 mb-3.5 min-w-76" onClick={submit}>
|
||||
{isProcessing ? 'Generating Keys...' : 'Submit'}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowForgotPasscode && (
|
||||
<Button
|
||||
className="flex min-w-76 items-center justify-center"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ChallengeModalValues } from './ChallengeModalValues'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { InputValue } from './InputValue'
|
||||
import BiometricsPrompt from './BiometricsPrompt'
|
||||
import U2FPrompt from './U2FPrompt'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -19,6 +20,7 @@ type Props = {
|
||||
index: number
|
||||
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
|
||||
isInvalid: boolean
|
||||
contextData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||
@@ -28,9 +30,11 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||
index,
|
||||
onValueChange,
|
||||
isInvalid,
|
||||
contextData,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const biometricsButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const authenticatorButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const activatePrompt = useCallback(async () => {
|
||||
if (prompt.validation === ChallengeValidation.Biometric) {
|
||||
@@ -137,6 +141,14 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||
prompt={prompt}
|
||||
buttonRef={biometricsButtonRef}
|
||||
/>
|
||||
) : prompt.validation === ChallengeValidation.Authenticator ? (
|
||||
<U2FPrompt
|
||||
application={application}
|
||||
onValueChange={onValueChange}
|
||||
prompt={prompt}
|
||||
buttonRef={authenticatorButtonRef}
|
||||
contextData={contextData}
|
||||
/>
|
||||
) : prompt.secureTextEntry ? (
|
||||
<DecoratedPasswordInput
|
||||
ref={inputRef}
|
||||
|
||||
@@ -2,6 +2,6 @@ import { ChallengePrompt } from '@standardnotes/snjs'
|
||||
|
||||
export type InputValue = {
|
||||
prompt: ChallengePrompt
|
||||
value: string | number | boolean
|
||||
value: string | number | boolean | Record<string, unknown>
|
||||
invalid: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ChallengePrompt } from '@standardnotes/services'
|
||||
import { RefObject, useState } from 'react'
|
||||
import Button from '../Button/Button'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { InputValue } from './InputValue'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
|
||||
prompt: ChallengePrompt
|
||||
buttonRef: RefObject<HTMLButtonElement>
|
||||
contextData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData }: Props) => {
|
||||
const [authenticatorResponse, setAuthenticatorResponse] = useState<Record<string, unknown> | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
return (
|
||||
<div className="min-w-76">
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
<Button
|
||||
primary
|
||||
fullWidth
|
||||
colorStyle={authenticatorResponse ? 'success' : 'info'}
|
||||
onClick={async () => {
|
||||
if (!contextData || contextData.username === undefined) {
|
||||
setError('No username provided')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const authenticatorResponseOrError = await application.getAuthenticatorAuthenticationResponse.execute({
|
||||
username: contextData.username,
|
||||
})
|
||||
if (authenticatorResponseOrError.isFailed()) {
|
||||
setError(authenticatorResponseOrError.getError())
|
||||
|
||||
return
|
||||
}
|
||||
const authenticatorResponse = authenticatorResponseOrError.getValue()
|
||||
|
||||
setAuthenticatorResponse(authenticatorResponse)
|
||||
onValueChange(authenticatorResponse, prompt)
|
||||
}}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{authenticatorResponse ? (
|
||||
<span className="flex items-center justify-center gap-3">
|
||||
<Icon type="check-circle" />
|
||||
Obtained Device Response
|
||||
</span>
|
||||
) : (
|
||||
'Authenticate Device'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default U2FPrompt
|
||||
@@ -11,6 +11,8 @@ import ErroredItems from './ErroredItems'
|
||||
import PreferencesPane from '@/Components/Preferences/PreferencesComponents/PreferencesPane'
|
||||
import BiometricsLock from '@/Components/Preferences/Panes/Security/BiometricsLock'
|
||||
import MultitaskingPrivacy from '@/Components/Preferences/Panes/Security/MultitaskingPrivacy'
|
||||
import U2FWrapper from './U2F/U2FWrapper'
|
||||
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
|
||||
|
||||
interface SecurityProps extends MfaProps {
|
||||
viewControllerManager: ViewControllerManager
|
||||
@@ -32,6 +34,9 @@ const Security: FunctionComponent<SecurityProps> = (props) => {
|
||||
userProvider={props.userProvider}
|
||||
application={props.application}
|
||||
/>
|
||||
{featureTrunkEnabled(FeatureTrunkName.U2F) && (
|
||||
<U2FWrapper userProvider={props.userProvider} application={props.application} />
|
||||
)}
|
||||
{isNativeMobileWeb && <MultitaskingPrivacy application={props.application} />}
|
||||
<PasscodeLock viewControllerManager={props.viewControllerManager} application={props.application} />
|
||||
{isNativeMobileWeb && <BiometricsLock application={props.application} />}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { FunctionComponent, useCallback, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { UseCaseInterface } from '@standardnotes/snjs'
|
||||
|
||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import { UserProvider } from '@/Components/Preferences/Providers'
|
||||
import Modal from '@/Components/Modal/Modal'
|
||||
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
||||
|
||||
type Props = {
|
||||
userProvider: UserProvider
|
||||
addAuthenticator: UseCaseInterface<void>
|
||||
onDeviceAddingModalToggle: (show: boolean) => void
|
||||
onDeviceAdded: () => Promise<void>
|
||||
}
|
||||
|
||||
const U2FAddDeviceView: FunctionComponent<Props> = ({
|
||||
userProvider,
|
||||
addAuthenticator,
|
||||
onDeviceAddingModalToggle,
|
||||
onDeviceAdded,
|
||||
}) => {
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
const handleDeviceNameChange = useCallback((deviceName: string) => {
|
||||
setDeviceName(deviceName)
|
||||
}, [])
|
||||
|
||||
const handleAddDeviceClick = useCallback(async () => {
|
||||
if (!deviceName) {
|
||||
setErrorMessage('Device name is required')
|
||||
return
|
||||
}
|
||||
|
||||
const user = userProvider.getUser()
|
||||
if (user === undefined) {
|
||||
setErrorMessage('User not found')
|
||||
return
|
||||
}
|
||||
|
||||
const authenticatorAddedOrError = await addAuthenticator.execute({
|
||||
userUuid: user.uuid,
|
||||
authenticatorName: deviceName,
|
||||
})
|
||||
if (authenticatorAddedOrError.isFailed()) {
|
||||
setErrorMessage(authenticatorAddedOrError.getError())
|
||||
return
|
||||
}
|
||||
|
||||
onDeviceAddingModalToggle(false)
|
||||
await onDeviceAdded()
|
||||
}, [deviceName, setErrorMessage, userProvider, addAuthenticator, onDeviceAddingModalToggle, onDeviceAdded])
|
||||
|
||||
const closeModal = () => {
|
||||
onDeviceAddingModalToggle(false)
|
||||
}
|
||||
|
||||
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add U2F Device"
|
||||
close={closeModal}
|
||||
actions={[
|
||||
{
|
||||
label: 'Cancel',
|
||||
type: 'cancel',
|
||||
onClick: closeModal,
|
||||
mobileSlot: 'left',
|
||||
hidden: !isMobileScreen,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
Add <span className="hidden md:inline">Device</span>
|
||||
</>
|
||||
),
|
||||
type: 'primary',
|
||||
onClick: handleAddDeviceClick,
|
||||
mobileSlot: 'right',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="w-25 h-25 flex items-center justify-center bg-info">...Some Cool Device Picture Here...</div>
|
||||
<div className="flex flex-grow flex-col gap-2">
|
||||
<DecoratedInput className={{ container: 'w-92 ml-4' }} value={deviceName} onChange={handleDeviceNameChange} />
|
||||
</div>
|
||||
{errorMessage && <div className="text-error">{errorMessage}</div>}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(U2FAddDeviceView)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { UserProvider } from '@/Components/Preferences/Providers'
|
||||
|
||||
export interface U2FProps {
|
||||
userProvider: UserProvider
|
||||
application: WebApplication
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import { UserProvider } from '@/Components/Preferences/Providers'
|
||||
|
||||
type Props = {
|
||||
userProvider: UserProvider
|
||||
}
|
||||
|
||||
const U2FDescription: FunctionComponent<Props> = ({ userProvider }) => {
|
||||
if (userProvider.getUser() === undefined) {
|
||||
return <Text>Sign in or register for an account to configure U2F.</Text>
|
||||
}
|
||||
|
||||
return <Text>Authenticate with a U2F hardware device.</Text>
|
||||
}
|
||||
|
||||
export default observer(U2FDescription)
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FunctionComponent, useCallback } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import Button from '@/Components/Button/Button'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
devices: Array<{ id: string; name: string }>
|
||||
onDeviceDeleted: () => Promise<void>
|
||||
onError: (error: string) => void
|
||||
}
|
||||
|
||||
const U2FDevicesList: FunctionComponent<Props> = ({ application, devices, onError, onDeviceDeleted }) => {
|
||||
const handleDeleteButtonOnClick = useCallback(
|
||||
async (authenticatorId: string) => {
|
||||
const deleteAuthenticatorOrError = await application.deleteAuthenticator.execute({
|
||||
authenticatorId,
|
||||
})
|
||||
|
||||
if (deleteAuthenticatorOrError.isFailed()) {
|
||||
onError(deleteAuthenticatorOrError.getError())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await onDeviceDeleted()
|
||||
},
|
||||
[application, onDeviceDeleted, onError],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
{devices.length > 0 && (
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div>
|
||||
<Text>Devices:</Text>
|
||||
</div>
|
||||
{devices.map((device) => (
|
||||
<div key="device-{device.id}">
|
||||
<Text>{device.name}</Text>
|
||||
<Button
|
||||
key={device.id}
|
||||
primary={true}
|
||||
label="Delete"
|
||||
onClick={async () => handleDeleteButtonOnClick(device.id)}
|
||||
></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(U2FDevicesList)
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { Title } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import { UserProvider } from '@/Components/Preferences/Providers'
|
||||
|
||||
type Props = {
|
||||
userProvider: UserProvider
|
||||
}
|
||||
|
||||
const U2FTitle: FunctionComponent<Props> = ({ userProvider }) => {
|
||||
if (userProvider.getUser() === undefined) {
|
||||
return <Title>Universal 2nd Factor authentication not available</Title>
|
||||
}
|
||||
|
||||
return <Title>Universal 2nd Factor authentication</Title>
|
||||
}
|
||||
|
||||
export default observer(U2FTitle)
|
||||
@@ -0,0 +1,80 @@
|
||||
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
|
||||
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { UserProvider } from '@/Components/Preferences/Providers'
|
||||
|
||||
import U2FTitle from './U2FTitle'
|
||||
import U2FDescription from './U2FDescription'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import U2FAddDeviceView from '../U2FAddDeviceView'
|
||||
import U2FDevicesList from './U2FDevicesList'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
userProvider: UserProvider
|
||||
}
|
||||
|
||||
const U2FView: FunctionComponent<Props> = ({ application, userProvider }) => {
|
||||
const [showDeviceAddingModal, setShowDeviceAddingModal] = useState(false)
|
||||
const [devices, setDevices] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleAddDeviceClick = useCallback(() => {
|
||||
setShowDeviceAddingModal(true)
|
||||
}, [])
|
||||
|
||||
const loadAuthenticatorDevices = useCallback(async () => {
|
||||
const authenticatorListOrError = await application.listAuthenticators.execute()
|
||||
if (authenticatorListOrError.isFailed()) {
|
||||
setError(authenticatorListOrError.getError())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setDevices(authenticatorListOrError.getValue())
|
||||
}, [setError, setDevices, application])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuthenticatorDevices().catch(console.error)
|
||||
}, [loadAuthenticatorDevices])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-grow flex-col">
|
||||
<U2FTitle userProvider={userProvider} />
|
||||
<U2FDescription userProvider={userProvider} />
|
||||
</div>
|
||||
<PreferencesSegment>
|
||||
<Button label="Add Device" primary onClick={handleAddDeviceClick} />
|
||||
</PreferencesSegment>
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
<U2FDevicesList
|
||||
application={application}
|
||||
devices={devices}
|
||||
onError={setError}
|
||||
onDeviceDeleted={loadAuthenticatorDevices}
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
{showDeviceAddingModal && (
|
||||
<U2FAddDeviceView
|
||||
onDeviceAddingModalToggle={setShowDeviceAddingModal}
|
||||
onDeviceAdded={loadAuthenticatorDevices}
|
||||
userProvider={userProvider}
|
||||
addAuthenticator={application.addAuthenticator}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(U2FView)
|
||||
@@ -0,0 +1,10 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
|
||||
import { U2FProps } from './U2FProps'
|
||||
import U2FView from './U2FView/U2FView'
|
||||
|
||||
const U2FWrapper: FunctionComponent<U2FProps> = (props) => {
|
||||
return <U2FView application={props.application} userProvider={props.userProvider} />
|
||||
}
|
||||
|
||||
export default U2FWrapper
|
||||
Reference in New Issue
Block a user