feat: add the ability to create and sign while user is offline #85
27
src/components/copyModal.tsx
Normal file
27
src/components/copyModal.tsx
Normal 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
|
51
src/components/copyToClipboard.tsx
Normal file
51
src/components/copyToClipboard.tsx
Normal 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
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user
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