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:
Karol Sójko
2023-02-03 07:54:56 +01:00
committed by GitHub
parent b4f14c668d
commit 9414774e89
48 changed files with 552 additions and 190 deletions

View File

@@ -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} />}

View File

@@ -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)

View File

@@ -0,0 +1,7 @@
import { WebApplication } from '@/Application/Application'
import { UserProvider } from '@/Components/Preferences/Providers'
export interface U2FProps {
userProvider: UserProvider
application: WebApplication
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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