feat: add the ability to create and sign while user is offline #85

Merged
s merged 5 commits from issue-73 into staging 2024-05-28 10:27:40 +00:00
4 changed files with 261 additions and 92 deletions
Showing only changes of commit c3c9bf772d - Show all commits

View File

@ -0,0 +1,27 @@
import { Dialog, DialogContent, DialogTitle } from '@mui/material'
import { CopyToClipboard } from './copyToClipboard'
interface CopyModalProps {
open: boolean
handleClose: () => void
title: string
textToCopy: string
}
export const CopyModal = ({
open,
handleClose,
title,
textToCopy
}: CopyModalProps) => {
return (
<Dialog open={open} onClose={handleClose} maxWidth="xs">
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<CopyToClipboard textToCopy={textToCopy} />
</DialogContent>
</Dialog>
)
}
export default CopyModal

View File

@ -0,0 +1,51 @@
import { ContentCopy } from '@mui/icons-material/'
import { Box, IconButton, Typography } from '@mui/material'
import { toast } from 'react-toastify'
type Props = {
textToCopy: string
}
export const CopyToClipboard = ({ textToCopy }: Props) => {
const handleCopyClick = () => {
navigator.clipboard.writeText(textToCopy)
toast.success('Copied to clipboard', {
autoClose: 1000,
hideProgressBar: true
})
}
return (
<Box
sx={{
s marked this conversation as resolved
Review

it would be nice to move css logic into separate file

it would be nice to move css logic into separate file
Review

it would be nice to move css logic into separate file

This is a simple utility component, I think it's fine to add inline style in this case. Otherwise, I'll create a folder and separate .scss file just for 1 class

> it would be nice to move css logic into separate file This is a simple utility component, I think it's fine to add inline style in this case. Otherwise, I'll create a folder and separate .scss file just for 1 class
display: 'flex',
alignItems: 'center',
width: '100%'
}}
>
<Typography
onClick={(e) => {
e.stopPropagation()
handleCopyClick()
}}
component="label"
sx={{
flex: '1',
overflow: 'auto',
whiteSpace: 'nowrap',
cursor: 'pointer'
}}
>
{textToCopy}
</Typography>
<IconButton
onClick={(e) => {
e.stopPropagation()
handleCopyClick()
}}
>
<ContentCopy />
</IconButton>
</Box>
)
}

View File

@ -47,11 +47,15 @@ import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend' import { HTML5Backend } from 'react-dnd-html5-backend'
import type { Identifier, XYCoord } from 'dnd-core' import type { Identifier, XYCoord } from 'dnd-core'
import { useDrag, useDrop } from 'react-dnd' import { useDrag, useDrop } from 'react-dnd'
import saveAs from 'file-saver'
import CopyModal from '../../components/copyModal'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [openCopyModal, setOpenCopyModel] = useState(false)
const [textToCopy, setTextToCopy] = useState('')
const [authUrl, setAuthUrl] = useState<string>() const [authUrl, setAuthUrl] = useState<string>()
@ -329,58 +333,65 @@ export const CreatePage = () => {
const encryptedArrayBuffer = await encryptArrayBuffer( const encryptedArrayBuffer = await encryptArrayBuffer(
arraybuffer, arraybuffer,
encryptionKey encryptionKey
) ).finally(() => setIsLoading(false))
const blob = new Blob([encryptedArrayBuffer]) const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.') if (navigator.onLine) {
const fileUrl = await uploadToFileStorage(blob, nostrController) setIsLoading(true)
.then((url) => { setLoadingSpinnerDesc('Uploading zip file to file storage.')
toast.success('zip file uploaded to file storage') const fileUrl = await uploadToFileStorage(blob, nostrController)
return url .then((url) => {
}) toast.success('zip file uploaded to file storage')
.catch((err) => { return url
console.log('err in upload:>> ', err) })
setIsLoading(false) .catch((err) => {
toast.error(err.message || 'Error occurred in uploading zip file') console.log('err in upload:>> ', err)
return null setIsLoading(false)
}) toast.error(err.message || 'Error occurred in uploading zip file')
return null
})
if (!fileUrl) return if (!fileUrl) return
setLoadingSpinnerDesc('Sending DM to signers/viewers') setLoadingSpinnerDesc('Sending DM to signers/viewers')
// send DM to first signer if exists // send DM to first signer if exists
if (signers.length > 0) { if (signers.length > 0) {
await sendDM(
fileUrl,
encryptionKey,
signers[0].pubkey,
nostrController,
true,
setAuthUrl
)
} else {
// send DM to all viewers if no signer
for (const viewer of viewers) {
// todo: execute in parallel
await sendDM( await sendDM(
fileUrl, fileUrl,
encryptionKey, encryptionKey,
viewer.pubkey, signers[0].pubkey,
nostrController, nostrController,
false, true,
setAuthUrl setAuthUrl
) )
} else {
// send DM to all viewers if no signer
for (const viewer of viewers) {
// todo: execute in parallel
await sendDM(
fileUrl,
encryptionKey,
viewer.pubkey,
nostrController,
false,
setAuthUrl
)
}
} }
} setIsLoading(false)
setIsLoading(false)
navigate( navigate(
`${appPrivateRoutes.sign}?file=${encodeURIComponent( `${appPrivateRoutes.sign}?file=${encodeURIComponent(
fileUrl fileUrl
)}&key=${encodeURIComponent(encryptionKey)}` )}&key=${encodeURIComponent(encryptionKey)}`
) )
} else {
saveAs(blob, 'request.sigit')
setTextToCopy(encryptionKey)
setOpenCopyModel(true)
}
} }
if (authUrl) { if (authUrl) {
@ -471,6 +482,15 @@ export const CreatePage = () => {
</> </>
)} )}
</Box> </Box>
<CopyModal
open={openCopyModal}
handleClose={() => {
setOpenCopyModel(false)
navigate(appPrivateRoutes.sign)
}}
title="Decryption key for Sigit file"
textToCopy={textToCopy}
/>
</> </>
) )
} }

View File

@ -59,7 +59,7 @@ import {
Download, Download,
HourglassTop HourglassTop
} from '@mui/icons-material' } from '@mui/icons-material'
import CopyModal from '../../components/copyModal'
enum SignedStatus { enum SignedStatus {
Fully_Signed, Fully_Signed,
User_Is_Next_Signer, User_Is_Next_Signer,
@ -79,6 +79,8 @@ export const SignPage = () => {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [openCopyModal, setOpenCopyModel] = useState(false)
const [textToCopy, setTextToCopy] = useState('')
const [meta, setMeta] = useState<Meta | null>(null) const [meta, setMeta] = useState<Meta | null>(null)
const [signedStatus, setSignedStatus] = useState<SignedStatus>() const [signedStatus, setSignedStatus] = useState<SignedStatus>()
@ -98,6 +100,9 @@ export const SignPage = () => {
const [nextSinger, setNextSinger] = useState<string>() const [nextSinger, setNextSinger] = useState<string>()
// This state variable indicates whether the logged-in user is a signer, a creator, or neither.
const [isSignerOrCreator, setIsSignerOrCreator] = useState(false)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const [authUrl, setAuthUrl] = useState<string>() const [authUrl, setAuthUrl] = useState<string>()
@ -167,7 +172,20 @@ export const SignPage = () => {
// there's no signer just viewers. So its fully signed // there's no signer just viewers. So its fully signed
setSignedStatus(SignedStatus.Fully_Signed) setSignedStatus(SignedStatus.Fully_Signed)
} }
}, [signers, signedBy, usersPubkey])
// Determine and set the status of the user
if (submittedBy && usersPubkey && submittedBy === usersPubkey) {
// If the submission was made by the user, set the status to true
setIsSignerOrCreator(true)
} else if (usersPubkey) {
// Convert the user's public key from hex to npub format
const usersNpub = hexToNpub(usersPubkey)
if (signers.includes(usersNpub)) {
// If the user's npub is in the list of signers, set the status to true
setIsSignerOrCreator(true)
}
}
}, [signers, signedBy, usersPubkey, submittedBy])
useEffect(() => { useEffect(() => {
const fileUrl = searchParams.get('file') const fileUrl = searchParams.get('file')
@ -236,6 +254,7 @@ export const SignPage = () => {
if (!zip) return if (!zip) return
setZip(zip) setZip(zip)
setDisplayInput(false)
setLoadingSpinnerDesc('Parsing meta.json') setLoadingSpinnerDesc('Parsing meta.json')
@ -414,75 +433,79 @@ export const SignPage = () => {
const blob = new Blob([encryptedArrayBuffer]) const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.') if (navigator.onLine) {
const fileUrl = await uploadToFileStorage(blob, nostrController) setLoadingSpinnerDesc('Uploading zip file to file storage.')
.then((url) => { const fileUrl = await uploadToFileStorage(blob, nostrController)
toast.success('zip file uploaded to file storage') .then((url) => {
return url toast.success('zip file uploaded to file storage')
}) return url
.catch((err) => { })
console.log('err in upload:>> ', err) .catch((err) => {
setIsLoading(false) console.log('err in upload:>> ', err)
toast.error(err.message || 'Error occurred in uploading zip file') setIsLoading(false)
return null toast.error(err.message || 'Error occurred in uploading zip file')
}) return null
})
if (!fileUrl) return if (!fileUrl) return
// check if the current user is the last signer // check if the current user is the last signer
const usersNpub = hexToNpub(usersPubkey!) const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1 const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub) const signerIndex = signers.indexOf(usersNpub)
const isLastSigner = signerIndex === lastSignerIndex const isLastSigner = signerIndex === lastSignerIndex
// if current user is the last signer, then send DMs to all signers and viewers // if current user is the last signer, then send DMs to all signers and viewers
if (isLastSigner) { if (isLastSigner) {
const userSet = new Set<`npub1${string}`>() const userSet = new Set<`npub1${string}`>()
if (submittedBy) { if (submittedBy) {
userSet.add(hexToNpub(submittedBy)) userSet.add(hexToNpub(submittedBy))
} }
signers.forEach((signer) => { signers.forEach((signer) => {
userSet.add(signer) userSet.add(signer)
}) })
viewers.forEach((viewer) => { viewers.forEach((viewer) => {
userSet.add(viewer) userSet.add(viewer)
}) })
const users = Array.from(userSet) const users = Array.from(userSet)
for (const user of users) { for (const user of users) {
// todo: execute in parallel // todo: execute in parallel
await sendDM(
fileUrl,
key,
npubToHex(user)!,
nostrController,
false,
setAuthUrl
)
}
} else {
const nextSigner = signers[signerIndex + 1]
await sendDM( await sendDM(
fileUrl, fileUrl,
key, key,
npubToHex(user)!, npubToHex(nextSigner)!,
nostrController, nostrController,
false, true,
setAuthUrl setAuthUrl
) )
} }
setIsLoading(false)
// update search params with updated file url and encryption key
setSearchParams({
file: fileUrl,
key: key
})
} else { } else {
const nextSigner = signers[signerIndex + 1] handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false))
await sendDM(
fileUrl,
key,
npubToHex(nextSigner)!,
nostrController,
true,
setAuthUrl
)
} }
setIsLoading(false)
// update search params with updated file url and encryption key
setSearchParams({
file: fileUrl,
key: key
})
} }
const handleExport = async () => { const handleExport = async () => {
@ -549,6 +572,37 @@ export const SignPage = () => {
navigate(appPrivateRoutes.verify) navigate(appPrivateRoutes.verify)
} }
const handleExportSigit = async () => {
if (!zip) return
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
})
if (!arrayBuffer) return
const key = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
const blob = new Blob([encryptedArrayBuffer])
saveAs(blob, 'exported.sigit')
setTextToCopy(key)
setOpenCopyModel(true)
}
/** /**
* This function accepts an npub of a signer and return the signature of its previous signer. * This function accepts an npub of a signer and return the signature of its previous signer.
* This prevSig will be used in the content of the provided signer's signedEvent * This prevSig will be used in the content of the provided signer's signedEvent
@ -637,6 +691,7 @@ export const SignPage = () => {
<Box className={styles.inputBlock}> <Box className={styles.inputBlock}>
<MuiFileInput <MuiFileInput
placeholder="Select file" placeholder="Select file"
inputProps={{ accept: '.sigit' }}
value={selectedFile} value={selectedFile}
onChange={(value) => setSelectedFile(value)} onChange={(value) => setSelectedFile(value)}
/> />
@ -675,6 +730,7 @@ export const SignPage = () => {
nextSigner={nextSinger} nextSigner={nextSinger}
getPrevSignersSig={getPrevSignersSig} getPrevSignersSig={getPrevSignersSig}
/> />
{signedStatus === SignedStatus.Fully_Signed && ( {signedStatus === SignedStatus.Fully_Signed && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}> <Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained"> <Button onClick={handleExport} variant="contained">
@ -690,9 +746,24 @@ export const SignPage = () => {
</Button> </Button>
</Box> </Box>
)} )}
{isSignerOrCreator &&
signedStatus === SignedStatus.User_Is_Not_Next_Signer && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExportSigit} variant="contained">
Export Sigit
</Button>
</Box>
)}
</> </>
)} )}
</Box> </Box>
<CopyModal
open={openCopyModal}
handleClose={() => setOpenCopyModel(false)}
title="Decryption key for Sigit file"
textToCopy={textToCopy}
/>
</> </>
) )
} }