Merge pull request 'feat: add the ability to create and sign while user is offline' (#85) from issue-73 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m4s

Reviewed-on: https://git.sigit.io/sig/it/pulls/85
Reviewed-by: Y <yury@4gl.io>
This commit is contained in:
s 2024-05-28 10:27:40 +00:00
commit 47c545b7f1
7 changed files with 266 additions and 97 deletions

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={{
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

@ -58,7 +58,7 @@ export class AuthController {
const { hostname } = window.location const { hostname } = window.location
const authEvent: EventTemplate = { const authEvent: EventTemplate = {
kind: 1, kind: 27235,
tags: [], tags: [],
content: `${hostname}-${timestamp}`, content: `${hostname}-${timestamp}`,
created_at: timestamp created_at: timestamp

View File

@ -206,15 +206,15 @@ export class MetadataController {
if (userRelays.length === 0) return null if (userRelays.length === 0) return null
// filter for finding user's first kind 1 event // filter for finding user's first kind 0 event
const eventFilter: Filter = { const eventFilter: Filter = {
kinds: [kinds.ShortTextNote], kinds: [kinds.Metadata],
authors: [hexKey] authors: [hexKey]
} }
const pool = new SimplePool() const pool = new SimplePool()
// find user's kind 1 events published on user's relays // find user's kind 0 events published on user's relays
const events = await pool.querySync(userRelays, eventFilter) const events = await pool.querySync(userRelays, eventFilter)
if (events && events.length) { if (events && events.length) {
// sort events by created_at time in ascending order // sort events by created_at time in ascending order

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

View File

@ -189,7 +189,7 @@ export const signEventForMetaFile = async (
) => { ) => {
// Construct the event metadata for the meta file // Construct the event metadata for the meta file
const event: EventTemplate = { const event: EventTemplate = {
kind: 1, // Event type for meta file kind: 27235, // Event type for meta file
content: content, // content for event content: content, // content for event
created_at: Math.floor(Date.now() / 1000), // Current timestamp created_at: Math.floor(Date.now() / 1000), // Current timestamp
tags: [] tags: []