1026 lines
28 KiB
TypeScript
1026 lines
28 KiB
TypeScript
import { Clear, DragHandle } from '@mui/icons-material'
|
|
import {
|
|
Box,
|
|
Button,
|
|
FormControl,
|
|
IconButton,
|
|
InputLabel,
|
|
MenuItem,
|
|
Paper,
|
|
Select,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
TextField,
|
|
Tooltip,
|
|
Typography
|
|
} from '@mui/material'
|
|
import type { Identifier, XYCoord } from 'dnd-core'
|
|
import saveAs from 'file-saver'
|
|
import JSZip from 'jszip'
|
|
import { MuiFileInput } from 'mui-file-input'
|
|
import { Event, kinds } from 'nostr-tools'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { DndProvider, useDrag, useDrop } from 'react-dnd'
|
|
import { HTML5Backend } from 'react-dnd-html5-backend'
|
|
import { useSelector } from 'react-redux'
|
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
import { toast } from 'react-toastify'
|
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
|
import { UserAvatar } from '../../components/UserAvatar'
|
|
import { MetadataController, NostrController } from '../../controllers'
|
|
import { appPrivateRoutes } from '../../routes'
|
|
import { State } from '../../store/rootReducer'
|
|
import {
|
|
CreateSignatureEventContent,
|
|
Meta,
|
|
ProfileMetadata,
|
|
User,
|
|
UserRole
|
|
} from '../../types'
|
|
import {
|
|
encryptArrayBuffer,
|
|
formatTimestamp,
|
|
generateEncryptionKey,
|
|
generateKeys,
|
|
generateKeysFile,
|
|
getHash,
|
|
hexToNpub,
|
|
isOnline,
|
|
now,
|
|
npubToHex,
|
|
queryNip05,
|
|
sendNotification,
|
|
shorten,
|
|
signEventForMetaFile,
|
|
updateUsersAppData,
|
|
uploadToFileStorage
|
|
} from '../../utils'
|
|
import styles from './style.module.scss'
|
|
import { PdfFile } from '../../types/drawing'
|
|
import { DrawPDFFields } from '../../components/DrawPDFFields'
|
|
import { Mark } from '../../types/mark.ts'
|
|
import { Container } from '../../components/Container'
|
|
|
|
export const CreatePage = () => {
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const { uploadedFile } = location.state || {}
|
|
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
|
|
|
const [authUrl, setAuthUrl] = useState<string>()
|
|
|
|
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
|
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
|
|
|
const [userInput, setUserInput] = useState('')
|
|
const [userRole, setUserRole] = useState<UserRole>(UserRole.signer)
|
|
const [error, setError] = useState<string>()
|
|
|
|
const [users, setUsers] = useState<User[]>([])
|
|
|
|
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
|
|
|
|
const nostrController = NostrController.getInstance()
|
|
|
|
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
|
|
{}
|
|
)
|
|
const [drawnPdfs, setDrawnPdfs] = useState<PdfFile[]>([])
|
|
|
|
useEffect(() => {
|
|
users.forEach((user) => {
|
|
if (!(user.pubkey in metadata)) {
|
|
const metadataController = new MetadataController()
|
|
|
|
const handleMetadataEvent = (event: Event) => {
|
|
const metadataContent =
|
|
metadataController.extractProfileMetadataContent(event)
|
|
if (metadataContent)
|
|
setMetadata((prev) => ({
|
|
...prev,
|
|
[user.pubkey]: metadataContent
|
|
}))
|
|
}
|
|
|
|
metadataController.on(user.pubkey, (kind: number, event: Event) => {
|
|
if (kind === kinds.Metadata) {
|
|
handleMetadataEvent(event)
|
|
}
|
|
})
|
|
|
|
metadataController
|
|
.findMetadata(user.pubkey)
|
|
.then((metadataEvent) => {
|
|
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
|
})
|
|
.catch((err) => {
|
|
console.error(
|
|
`error occurred in finding metadata for: ${user.pubkey}`,
|
|
err
|
|
)
|
|
})
|
|
}
|
|
})
|
|
}, [])
|
|
// Set up event listener for authentication event
|
|
nostrController.on('nsecbunker-auth', (url) => {
|
|
setAuthUrl(url)
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (uploadedFile) {
|
|
setSelectedFiles([uploadedFile])
|
|
}
|
|
}, [uploadedFile])
|
|
|
|
useEffect(() => {
|
|
if (usersPubkey) {
|
|
setUsers((prev) => {
|
|
const existingUserIndex = prev.findIndex(
|
|
(user) => user.pubkey === usersPubkey
|
|
)
|
|
|
|
// make logged in user the first signer by default
|
|
if (existingUserIndex === -1)
|
|
return [{ pubkey: usersPubkey, role: UserRole.signer }, ...prev]
|
|
|
|
return prev
|
|
})
|
|
}
|
|
}, [usersPubkey])
|
|
|
|
const handleAddUser = async () => {
|
|
setError(undefined)
|
|
|
|
const addUser = (pubkey: string) => {
|
|
setUsers((prev) => {
|
|
const signers = prev.filter((user) => user.role === UserRole.signer)
|
|
const viewers = prev.filter((user) => user.role === UserRole.viewer)
|
|
|
|
const existingUserIndex = prev.findIndex(
|
|
(user) => user.pubkey === pubkey
|
|
)
|
|
|
|
// add new
|
|
if (existingUserIndex === -1) {
|
|
if (userRole === UserRole.signer) {
|
|
return [...signers, { pubkey, role: userRole }, ...viewers]
|
|
} else {
|
|
return [...signers, ...viewers, { pubkey, role: userRole }]
|
|
}
|
|
}
|
|
|
|
const existingUser = prev[existingUserIndex]
|
|
|
|
// return existing
|
|
if (existingUser.role === userRole) return prev
|
|
|
|
// change user role
|
|
const updatedUsers = [...prev]
|
|
const updatedUser = { ...updatedUsers[existingUserIndex] }
|
|
updatedUser.role = userRole
|
|
updatedUsers[existingUserIndex] = updatedUser
|
|
|
|
// signers should be placed at the start of the array
|
|
return [
|
|
...updatedUsers.filter((user) => user.role === UserRole.signer),
|
|
...updatedUsers.filter((user) => user.role === UserRole.viewer)
|
|
]
|
|
})
|
|
}
|
|
|
|
const input = userInput.toLowerCase()
|
|
|
|
if (input.startsWith('npub')) {
|
|
const pubkey = npubToHex(input)
|
|
if (pubkey) {
|
|
addUser(pubkey)
|
|
setUserInput('')
|
|
} else {
|
|
setError('Provided npub is not valid. Please enter correct npub.')
|
|
}
|
|
return
|
|
}
|
|
|
|
if (input.includes('@')) {
|
|
setIsLoading(true)
|
|
setLoadingSpinnerDesc('Querying for nip05')
|
|
const nip05Profile = await queryNip05(input)
|
|
.catch((err) => {
|
|
console.error(`error occurred in querying nip05: ${input}`, err)
|
|
return null
|
|
})
|
|
.finally(() => {
|
|
setIsLoading(false)
|
|
setLoadingSpinnerDesc('')
|
|
})
|
|
|
|
if (nip05Profile && nip05Profile.pubkey) {
|
|
const pubkey = nip05Profile.pubkey
|
|
addUser(pubkey)
|
|
setUserInput('')
|
|
} else {
|
|
setError('Provided nip05 is not valid. Please enter correct nip05.')
|
|
}
|
|
return
|
|
}
|
|
|
|
setError('Invalid input! Make sure to provide correct npub or nip05.')
|
|
}
|
|
|
|
const handleUserRoleChange = (role: UserRole, pubkey: string) => {
|
|
setUsers((prevUsers) =>
|
|
prevUsers.map((user) => {
|
|
if (user.pubkey === pubkey) {
|
|
return {
|
|
...user,
|
|
role
|
|
}
|
|
}
|
|
|
|
return user
|
|
})
|
|
)
|
|
}
|
|
|
|
const handleRemoveUser = (pubkey: string) => {
|
|
setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey))
|
|
}
|
|
|
|
/**
|
|
* changes the position of signer in the signers list
|
|
*
|
|
* @param dragIndex represents the current position of user
|
|
* @param hoverIndex represents the target position of user
|
|
*/
|
|
const moveSigner = (dragIndex: number, hoverIndex: number) => {
|
|
setUsers((prevUsers) => {
|
|
const updatedUsers = [...prevUsers]
|
|
const [draggedUser] = updatedUsers.splice(dragIndex, 1)
|
|
updatedUsers.splice(hoverIndex, 0, draggedUser)
|
|
return updatedUsers
|
|
})
|
|
}
|
|
|
|
const handleSelectFiles = (files: File[]) => {
|
|
setSelectedFiles((prev) => {
|
|
const prevFileNames = prev.map((file) => file.name)
|
|
|
|
const newFiles = files.filter(
|
|
(file) => !prevFileNames.includes(file.name)
|
|
)
|
|
|
|
return [...prev, ...newFiles]
|
|
})
|
|
}
|
|
|
|
const handleRemoveFile = (fileToRemove: File) => {
|
|
setSelectedFiles((prevFiles) =>
|
|
prevFiles.filter((file) => file.name !== fileToRemove.name)
|
|
)
|
|
}
|
|
|
|
// Validate inputs before proceeding
|
|
const validateInputs = (): boolean => {
|
|
if (!title.trim()) {
|
|
toast.error('Title can not be empty')
|
|
return false
|
|
}
|
|
|
|
if (users.length === 0) {
|
|
toast.error(
|
|
'No signer/viewer is provided. At least add one signer or viewer.'
|
|
)
|
|
return false
|
|
}
|
|
|
|
if (selectedFiles.length === 0) {
|
|
toast.error('No file is selected. Select at least 1 file')
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Handle errors during file arrayBuffer conversion
|
|
const handleFileError = (file: File) => (err: any) => {
|
|
console.log(
|
|
`Error while getting arrayBuffer of file ${file.name} :>> `,
|
|
err
|
|
)
|
|
toast.error(
|
|
err.message || `Error while getting arrayBuffer of file ${file.name}`
|
|
)
|
|
return null
|
|
}
|
|
|
|
// Generate hash for each selected file
|
|
const generateFileHashes = async (): Promise<{
|
|
[key: string]: string
|
|
} | null> => {
|
|
const fileHashes: { [key: string]: string } = {}
|
|
|
|
for (const file of selectedFiles) {
|
|
const arraybuffer = await file.arrayBuffer().catch(handleFileError(file))
|
|
if (!arraybuffer) return null
|
|
|
|
const hash = await getHash(arraybuffer)
|
|
if (!hash) {
|
|
return null
|
|
}
|
|
|
|
fileHashes[file.name] = hash
|
|
}
|
|
|
|
return fileHashes
|
|
}
|
|
|
|
const createMarks = (fileHashes: { [key: string]: string }) : Mark[] => {
|
|
return drawnPdfs.flatMap((drawnPdf) => {
|
|
const fileHash = fileHashes[drawnPdf.file.name];
|
|
return drawnPdf.pages.flatMap((page, index) => {
|
|
return page.drawnFields.map((drawnField) => {
|
|
return {
|
|
type: drawnField.type,
|
|
location: {
|
|
page: index,
|
|
top: drawnField.top,
|
|
left: drawnField.left,
|
|
height: drawnField.height,
|
|
width: drawnField.width,
|
|
},
|
|
npub: drawnField.counterpart,
|
|
pdfFileHash: fileHash
|
|
}
|
|
})
|
|
})
|
|
})
|
|
.map((mark, index) => {
|
|
return {...mark, id: index }
|
|
});
|
|
}
|
|
|
|
// Handle errors during zip file generation
|
|
const handleZipError = (err: any) => {
|
|
console.log('Error in zip:>> ', err)
|
|
setIsLoading(false)
|
|
toast.error(err.message || 'Error occurred in generating zip file')
|
|
return null
|
|
}
|
|
|
|
// Generate the zip file
|
|
const generateZipFile = async (zip: JSZip): Promise<ArrayBuffer | null> => {
|
|
setLoadingSpinnerDesc('Generating zip file')
|
|
|
|
return await zip
|
|
.generateAsync({
|
|
type: 'arraybuffer',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 }
|
|
})
|
|
.catch(handleZipError)
|
|
}
|
|
|
|
// Encrypt the zip file with the generated encryption key
|
|
const encryptZipFile = async (
|
|
arraybuffer: ArrayBuffer,
|
|
encryptionKey: string
|
|
): Promise<ArrayBuffer> => {
|
|
setLoadingSpinnerDesc('Encrypting zip file')
|
|
return encryptArrayBuffer(arraybuffer, encryptionKey)
|
|
}
|
|
|
|
// create final zip file for offline mode
|
|
const createFinalZipFile = async (
|
|
encryptedArrayBuffer: ArrayBuffer,
|
|
encryptionKey: string
|
|
): Promise<File | null> => {
|
|
// Get the current timestamp in seconds
|
|
const unixNow = now()
|
|
const blob = new Blob([encryptedArrayBuffer])
|
|
// Create a File object with the Blob data
|
|
const file = new File([blob], `compressed.sigit`, {
|
|
type: 'application/sigit'
|
|
})
|
|
|
|
const firstSigner = users.filter((user) => user.role === UserRole.signer)[0]
|
|
|
|
const keysFileContent = await generateKeysFile(
|
|
[firstSigner.pubkey],
|
|
encryptionKey
|
|
)
|
|
if (!keysFileContent) return null
|
|
|
|
const zip = new JSZip()
|
|
zip.file(`compressed.sigit`, file)
|
|
zip.file('keys.json', keysFileContent)
|
|
|
|
const arraybuffer = await zip
|
|
.generateAsync({
|
|
type: 'arraybuffer',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 }
|
|
})
|
|
.catch(handleZipError)
|
|
|
|
if (!arraybuffer) return null
|
|
|
|
return new File(
|
|
[new Blob([arraybuffer])],
|
|
`${unixNow}.sigit.zip`,
|
|
{
|
|
type: 'application/zip'
|
|
}
|
|
)
|
|
}
|
|
|
|
// Handle errors during file upload
|
|
const handleUploadError = (err: any) => {
|
|
console.log('Error in upload:>> ', err)
|
|
setIsLoading(false)
|
|
toast.error(err.message || 'Error occurred in uploading file')
|
|
return null
|
|
}
|
|
|
|
// Upload the file to the storage
|
|
const uploadFile = async (
|
|
arrayBuffer: ArrayBuffer
|
|
): Promise<string | null> => {
|
|
const unixNow = now()
|
|
const blob = new Blob([arrayBuffer])
|
|
// Create a File object with the Blob data
|
|
const file = new File([blob], `compressed-${unixNow}.sigit`, {
|
|
type: 'application/sigit'
|
|
})
|
|
|
|
return await uploadToFileStorage(file)
|
|
.then((url) => {
|
|
toast.success('files.zip uploaded to file storage')
|
|
return url
|
|
})
|
|
.catch(handleUploadError)
|
|
}
|
|
|
|
// Manage offline scenarios for signing or viewing the file
|
|
const handleOfflineFlow = async (
|
|
encryptedArrayBuffer: ArrayBuffer,
|
|
encryptionKey: string
|
|
) => {
|
|
const finalZipFile = await createFinalZipFile(
|
|
encryptedArrayBuffer,
|
|
encryptionKey
|
|
)
|
|
|
|
if (!finalZipFile) return
|
|
|
|
saveAs(finalZipFile, 'request.sigit.zip')
|
|
}
|
|
|
|
const generateFilesZip = async (): Promise<ArrayBuffer | null> => {
|
|
const zip = new JSZip()
|
|
selectedFiles.forEach((file) => {
|
|
zip.file(file.name, file)
|
|
})
|
|
|
|
return await zip
|
|
.generateAsync({
|
|
type: 'arraybuffer',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 }
|
|
})
|
|
.catch(handleZipError)
|
|
}
|
|
|
|
const generateCreateSignature = async (
|
|
fileHashes: {
|
|
[key: string]: string
|
|
},
|
|
zipUrl: string
|
|
) => {
|
|
const signers = users.filter((user) => user.role === UserRole.signer)
|
|
const viewers = users.filter((user) => user.role === UserRole.viewer)
|
|
const markConfig = createMarks(fileHashes)
|
|
|
|
const content: CreateSignatureEventContent = {
|
|
signers: signers.map((signer) => hexToNpub(signer.pubkey)),
|
|
viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)),
|
|
fileHashes,
|
|
markConfig,
|
|
zipUrl,
|
|
title
|
|
}
|
|
|
|
setLoadingSpinnerDesc('Signing nostr event for create signature')
|
|
|
|
const createSignature = await signEventForMetaFile(
|
|
JSON.stringify(content),
|
|
nostrController,
|
|
setIsLoading
|
|
).catch(() => {
|
|
console.log('An error occurred in signing event for meta file', error)
|
|
toast.error('An error occurred in signing event for meta file')
|
|
return null
|
|
})
|
|
|
|
if (!createSignature) return null
|
|
|
|
return JSON.stringify(createSignature, null, 2)
|
|
}
|
|
|
|
// Send notifications to signers and viewers
|
|
const sendNotifications = (meta: Meta) => {
|
|
const signers = users.filter((user) => user.role === UserRole.signer)
|
|
const viewers = users.filter((user) => user.role === UserRole.viewer)
|
|
|
|
// no need to send notification to self so remove it from the list
|
|
const receivers = (
|
|
signers.length > 0
|
|
? [signers[0].pubkey]
|
|
: viewers.map((viewer) => viewer.pubkey)
|
|
).filter((receiver) => receiver !== usersPubkey)
|
|
|
|
return receivers.map((receiver) =>
|
|
sendNotification(receiver, meta)
|
|
)
|
|
}
|
|
|
|
const handleCreate = async () => {
|
|
if (!validateInputs()) return
|
|
|
|
setIsLoading(true)
|
|
setLoadingSpinnerDesc('Generating file hashes')
|
|
const fileHashes = await generateFileHashes()
|
|
if (!fileHashes) {
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
setLoadingSpinnerDesc('Generating encryption key')
|
|
const encryptionKey = await generateEncryptionKey()
|
|
|
|
if (await isOnline()) {
|
|
setLoadingSpinnerDesc('generating files.zip')
|
|
const arrayBuffer = await generateFilesZip()
|
|
if (!arrayBuffer) {
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
setLoadingSpinnerDesc('Encrypting files.zip')
|
|
const encryptedArrayBuffer = await encryptZipFile(
|
|
arrayBuffer,
|
|
encryptionKey
|
|
)
|
|
|
|
setLoadingSpinnerDesc('Uploading files.zip to file storage')
|
|
const fileUrl = await uploadFile(encryptedArrayBuffer)
|
|
if (!fileUrl) {
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
setLoadingSpinnerDesc('Generating create signature')
|
|
const createSignature = await generateCreateSignature(fileHashes, fileUrl)
|
|
if (!createSignature) {
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
setLoadingSpinnerDesc('Generating keys for decryption')
|
|
|
|
// generate key pairs for decryption
|
|
const pubkeys = users.map((user) => user.pubkey)
|
|
// also add creator in the list
|
|
if (pubkeys.includes(usersPubkey!)) {
|
|
pubkeys.push(usersPubkey!)
|
|
}
|
|
|
|
const keys = await generateKeys(pubkeys, encryptionKey)
|
|
|
|
if (!keys) {
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
const meta: Meta = {
|
|
createSignature,
|
|
keys,
|
|
modifiedAt: now(),
|
|
docSignatures: {}
|
|
}
|
|
|
|
setLoadingSpinnerDesc('Updating user app data')
|
|
const event = await updateUsersAppData(meta)
|
|
if (!event) {
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
setLoadingSpinnerDesc('Sending notifications to counterparties')
|
|
const promises = sendNotifications(meta)
|
|
|
|
await Promise.all(promises)
|
|
.then(() => {
|
|
toast.success('Notifications sent successfully')
|
|
})
|
|
.catch(() => {
|
|
toast.error('Failed to publish notifications')
|
|
})
|
|
|
|
navigate(appPrivateRoutes.sign, { state: { meta: meta } })
|
|
} else {
|
|
const zip = new JSZip()
|
|
|
|
selectedFiles.forEach((file) => {
|
|
zip.file(`files/${file.name}`, file)
|
|
})
|
|
|
|
setLoadingSpinnerDesc('Generating create signature')
|
|
const createSignature = await generateCreateSignature(fileHashes, '')
|
|
if (!createSignature) {
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
const meta: Meta = {
|
|
createSignature,
|
|
modifiedAt: now(),
|
|
docSignatures: {}
|
|
}
|
|
|
|
// add meta to zip
|
|
try {
|
|
const stringifiedMeta = JSON.stringify(meta, null, 2)
|
|
zip.file('meta.json', stringifiedMeta)
|
|
} catch (err) {
|
|
console.error(err)
|
|
toast.error('An error occurred in converting meta json to string')
|
|
return null
|
|
}
|
|
|
|
const arrayBuffer = await generateZipFile(zip)
|
|
if (!arrayBuffer) return
|
|
|
|
setLoadingSpinnerDesc('Encrypting zip file')
|
|
const encryptedArrayBuffer = await encryptZipFile(
|
|
arrayBuffer,
|
|
encryptionKey
|
|
)
|
|
|
|
await handleOfflineFlow(encryptedArrayBuffer, encryptionKey)
|
|
}
|
|
}
|
|
|
|
const onDrawFieldsChange = (pdfFiles: PdfFile[]) => {
|
|
setDrawnPdfs(pdfFiles)
|
|
}
|
|
|
|
if (authUrl) {
|
|
return (
|
|
<iframe
|
|
title="Nsecbunker auth"
|
|
src={authUrl}
|
|
width="100%"
|
|
height="500px"
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
|
<Container className={styles.container}>
|
|
<TextField
|
|
label="Title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
variant="outlined"
|
|
/>
|
|
|
|
<Box>
|
|
<MuiFileInput
|
|
fullWidth
|
|
multiple
|
|
placeholder="Choose Files"
|
|
value={selectedFiles}
|
|
onChange={(value) => handleSelectFiles(value)}
|
|
/>
|
|
|
|
{selectedFiles.length > 0 && (
|
|
<ul>
|
|
{selectedFiles.map((file, index) => (
|
|
<li key={index}>
|
|
<Typography component="label">{file.name}</Typography>
|
|
<IconButton onClick={() => handleRemoveFile(file)}>
|
|
<Clear style={{ color: 'red' }} />{' '}
|
|
</IconButton>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Box>
|
|
|
|
<Typography component="label" variant="h6">
|
|
Add Counterparts
|
|
</Typography>
|
|
<Box className={styles.inputBlock}>
|
|
<Box className={styles.inputBlock}>
|
|
<TextField
|
|
label="nip05 / npub"
|
|
value={userInput}
|
|
onChange={(e) => setUserInput(e.target.value)}
|
|
helperText={error}
|
|
error={!!error}
|
|
/>
|
|
<FormControl fullWidth>
|
|
<InputLabel id="select-role-label">Role</InputLabel>
|
|
<Select
|
|
labelId="select-role-label"
|
|
id="demo-simple-select"
|
|
value={userRole}
|
|
label="Role"
|
|
onChange={(e) => setUserRole(e.target.value as UserRole)}
|
|
>
|
|
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
|
|
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
|
<Button
|
|
disabled={!userInput}
|
|
onClick={handleAddUser}
|
|
variant="contained"
|
|
>
|
|
Add
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
<DisplayUser
|
|
metadata={metadata}
|
|
users={users}
|
|
handleUserRoleChange={handleUserRoleChange}
|
|
handleRemoveUser={handleRemoveUser}
|
|
moveSigner={moveSigner}
|
|
/>
|
|
|
|
<DrawPDFFields
|
|
metadata={metadata}
|
|
users={users}
|
|
selectedFiles={selectedFiles}
|
|
onDrawFieldsChange={onDrawFieldsChange}
|
|
/>
|
|
|
|
<Box sx={{ mt: 1, mb: 5, display: 'flex', justifyContent: 'center' }}>
|
|
<Button onClick={handleCreate} variant="contained">
|
|
Create
|
|
</Button>
|
|
</Box>
|
|
</Container>
|
|
</>
|
|
)
|
|
}
|
|
|
|
type DisplayUsersProps = {
|
|
metadata: { [key: string]: ProfileMetadata }
|
|
users: User[]
|
|
handleUserRoleChange: (role: UserRole, pubkey: string) => void
|
|
handleRemoveUser: (pubkey: string) => void
|
|
moveSigner: (dragIndex: number, hoverIndex: number) => void
|
|
}
|
|
|
|
const DisplayUser = ({
|
|
metadata,
|
|
users,
|
|
handleUserRoleChange,
|
|
handleRemoveUser,
|
|
moveSigner
|
|
}: DisplayUsersProps) => {
|
|
return (
|
|
<TableContainer component={Paper} elevation={3} sx={{ marginTop: '20px' }}>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell className={styles.tableHeaderCell}>User</TableCell>
|
|
<TableCell className={styles.tableHeaderCell}>Role</TableCell>
|
|
<TableCell>Action</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
<DndProvider backend={HTML5Backend}>
|
|
{users
|
|
.filter((user) => user.role === UserRole.signer)
|
|
.map((user, index) => (
|
|
<SignerRow
|
|
key={`signer-${index}`}
|
|
userMeta={metadata[user.pubkey]}
|
|
user={user}
|
|
index={index}
|
|
moveSigner={moveSigner}
|
|
handleUserRoleChange={handleUserRoleChange}
|
|
handleRemoveUser={handleRemoveUser}
|
|
/>
|
|
))}
|
|
</DndProvider>
|
|
{users
|
|
.filter((user) => user.role === UserRole.viewer)
|
|
.map((user, index) => {
|
|
const userMeta = metadata[user.pubkey]
|
|
return (
|
|
<TableRow key={index}>
|
|
<TableCell className={styles.tableCell}>
|
|
<UserAvatar
|
|
pubkey={user.pubkey}
|
|
name={
|
|
userMeta?.display_name ||
|
|
userMeta?.name ||
|
|
shorten(hexToNpub(user.pubkey))
|
|
}
|
|
image={userMeta?.picture}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className={styles.tableCell}>
|
|
<Select
|
|
fullWidth
|
|
value={user.role}
|
|
onChange={(e) =>
|
|
handleUserRoleChange(
|
|
e.target.value as UserRole,
|
|
user.pubkey
|
|
)
|
|
}
|
|
>
|
|
<MenuItem value={UserRole.signer}>
|
|
{UserRole.signer}
|
|
</MenuItem>
|
|
<MenuItem value={UserRole.viewer}>
|
|
{UserRole.viewer}
|
|
</MenuItem>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Tooltip title="Remove User" arrow>
|
|
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
|
|
<Clear style={{ color: 'red' }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)
|
|
}
|
|
|
|
interface DragItem {
|
|
index: number
|
|
id: string
|
|
type: string
|
|
}
|
|
|
|
type SignerRowProps = {
|
|
userMeta: ProfileMetadata
|
|
user: User
|
|
index: number
|
|
moveSigner: (dragIndex: number, hoverIndex: number) => void
|
|
handleUserRoleChange: (role: UserRole, pubkey: string) => void
|
|
handleRemoveUser: (pubkey: string) => void
|
|
}
|
|
|
|
const SignerRow = ({
|
|
userMeta,
|
|
user,
|
|
index,
|
|
moveSigner,
|
|
handleUserRoleChange,
|
|
handleRemoveUser
|
|
}: SignerRowProps) => {
|
|
const ref = useRef<HTMLTableRowElement>(null)
|
|
|
|
const [{ handlerId }, drop] = useDrop<
|
|
DragItem,
|
|
void,
|
|
{ handlerId: Identifier | null }
|
|
>({
|
|
accept: 'row',
|
|
collect(monitor) {
|
|
return {
|
|
handlerId: monitor.getHandlerId()
|
|
}
|
|
},
|
|
hover(item: DragItem, monitor) {
|
|
if (!ref.current) {
|
|
return
|
|
}
|
|
const dragIndex = item.index
|
|
const hoverIndex = index
|
|
|
|
// Don't replace items with themselves
|
|
if (dragIndex === hoverIndex) {
|
|
return
|
|
}
|
|
|
|
// Determine rectangle on screen
|
|
const hoverBoundingRect = ref.current?.getBoundingClientRect()
|
|
|
|
// Get vertical middle
|
|
const hoverMiddleY =
|
|
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2
|
|
|
|
// Determine mouse position
|
|
const clientOffset = monitor.getClientOffset()
|
|
|
|
// Get pixels to the top
|
|
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top
|
|
|
|
// Only perform the move when the mouse has crossed half of the items height
|
|
// When dragging downwards, only move when the cursor is below 50%
|
|
// When dragging upwards, only move when the cursor is above 50%
|
|
|
|
// Dragging downwards
|
|
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
|
return
|
|
}
|
|
|
|
// Dragging upwards
|
|
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
|
return
|
|
}
|
|
|
|
// Time to actually perform the action
|
|
moveSigner(dragIndex, hoverIndex)
|
|
|
|
// Note: we're mutating the monitor item here!
|
|
// Generally it's better to avoid mutations,
|
|
// but it's good here for the sake of performance
|
|
// to avoid expensive index searches.
|
|
item.index = hoverIndex
|
|
}
|
|
})
|
|
|
|
const [{ isDragging }, drag] = useDrag({
|
|
type: 'row',
|
|
item: () => {
|
|
return { id: user.pubkey, index }
|
|
},
|
|
collect: (monitor: any) => ({
|
|
isDragging: monitor.isDragging()
|
|
})
|
|
})
|
|
|
|
const opacity = isDragging ? 0 : 1
|
|
drag(drop(ref))
|
|
|
|
return (
|
|
<TableRow
|
|
sx={{ cursor: 'move', opacity }}
|
|
data-handler-id={handlerId}
|
|
ref={ref}
|
|
>
|
|
<TableCell
|
|
className={styles.tableCell}
|
|
sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}
|
|
>
|
|
<DragHandle />
|
|
<UserAvatar
|
|
pubkey={user.pubkey}
|
|
name={
|
|
userMeta?.display_name ||
|
|
userMeta?.name ||
|
|
shorten(hexToNpub(user.pubkey))
|
|
}
|
|
image={userMeta?.picture}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className={styles.tableCell}>
|
|
<Select
|
|
fullWidth
|
|
value={user.role}
|
|
onChange={(e) =>
|
|
handleUserRoleChange(e.target.value as UserRole, user.pubkey)
|
|
}
|
|
>
|
|
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
|
|
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Tooltip title="Remove User" arrow>
|
|
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
|
|
<Clear style={{ color: 'red' }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
}
|