diff --git a/src/components/FilesUsers.tsx/index.tsx b/src/components/FilesUsers.tsx/index.tsx new file mode 100644 index 0000000..59e3a09 --- /dev/null +++ b/src/components/FilesUsers.tsx/index.tsx @@ -0,0 +1,246 @@ +import { CheckCircle, Cancel } from '@mui/icons-material' +import { Divider, Tooltip } from '@mui/material' +import { verifyEvent } from 'nostr-tools' +import { useSigitProfiles } from '../../hooks/useSigitProfiles' +import { Meta, SignedEventContent } from '../../types' +import { + extractFileExtensions, + formatTimestamp, + fromUnixTimestamp, + hexToNpub, + npubToHex, + shorten +} from '../../utils' +import { UserAvatar } from '../UserAvatar' +import { useSigitMeta } from '../../hooks/useSigitMeta' +import { UserAvatarGroup } from '../UserAvatarGroup' + +import styles from './style.module.scss' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + faCalendar, + faCalendarCheck, + faCalendarPlus, + faEye, + faFile, + faFileCircleExclamation +} from '@fortawesome/free-solid-svg-icons' +import { getExtensionIconLabel } from '../getExtensionIconLabel' +import { useSelector } from 'react-redux' +import { State } from '../../store/rootReducer' + +interface FileUsersProps { + meta: Meta +} + +export const FileUsers = ({ meta }: FileUsersProps) => { + const { usersPubkey } = useSelector((state: State) => state.auth) + const { + submittedBy, + signers, + viewers, + fileHashes, + sig, + docSignatures, + parsedSignatureEvents, + createdAt, + signedStatus, + completedAt + } = useSigitMeta(meta) + const profiles = useSigitProfiles([ + ...(submittedBy ? [submittedBy] : []), + ...signers, + ...viewers + ]) + const userCanSign = + typeof usersPubkey !== 'undefined' && + signers.includes(hexToNpub(usersPubkey)) + + const ext = extractFileExtensions(Object.keys(fileHashes)) + + const getPrevSignersSig = (npub: string) => { + // if user is first signer then use creator's signature + if (signers[0] === npub) { + return sig + } + + // find the index of signer + const currentSignerIndex = signers.findIndex((signer) => signer === npub) + // return null if could not found user in signer's list + if (currentSignerIndex === -1) return null + // find prev signer + const prevSigner = signers[currentSignerIndex - 1] + + // get the signature of prev signer + try { + const prevSignersEvent = parsedSignatureEvents[prevSigner] + return prevSignersEvent.sig + } catch (error) { + return null + } + } + + const displayUser = (pubkey: string, verifySignature = false) => { + const profile = profiles[pubkey] + + let isValidSignature = false + + if (verifySignature) { + const npub = hexToNpub(pubkey) + const signedEventString = docSignatures[npub] + if (signedEventString) { + try { + const signedEvent = JSON.parse(signedEventString) + const isVerifiedEvent = verifyEvent(signedEvent) + + if (isVerifiedEvent) { + // get the actual signature of prev signer + const prevSignersSig = getPrevSignersSig(npub) + + // get the signature of prev signer from the content of current signers signedEvent + + try { + const obj: SignedEventContent = JSON.parse(signedEvent.content) + if ( + obj.prevSig && + prevSignersSig && + obj.prevSig === prevSignersSig + ) { + isValidSignature = true + } + } catch (error) { + isValidSignature = false + } + } + } catch (error) { + console.error( + `An error occurred in parsing and verifying the signature event for ${pubkey}`, + error + ) + } + } + } + + return ( + <> + + + {verifySignature && ( + <> + {isValidSignature && ( + + + + )} + + {!isValidSignature && ( + + + + )} + + )} + + ) + } + + return submittedBy ? ( +
+
+

Signers

+ {displayUser(submittedBy)} + {submittedBy && signers.length ? ( + + ) : null} + + {signers.length > 0 && + signers.map((signer) => ( + {displayUser(npubToHex(signer)!, true)} + ))} + {viewers.length > 0 && + viewers.map((viewer) => ( + {displayUser(npubToHex(viewer)!)} + ))} + +
+
+

Details

+ + + + {' '} + {createdAt ? formatTimestamp(createdAt) : <>—} + + + + + + {' '} + {completedAt ? formatTimestamp(completedAt) : <>—} + + + + {/* User signed date */} + {userCanSign ? ( + + + {' '} + {hexToNpub(usersPubkey) in parsedSignatureEvents ? ( + parsedSignatureEvents[hexToNpub(usersPubkey)].created_at ? ( + formatTimestamp( + fromUnixTimestamp( + parsedSignatureEvents[hexToNpub(usersPubkey)].created_at + ) + ) + ) : ( + <>— + ) + ) : ( + <>— + )} + + + ) : null} + + {signedStatus} + + {ext.length > 0 ? ( + + {ext.length > 1 ? ( + <> + Multiple File Types + + ) : ( + getExtensionIconLabel(ext[0]) + )} + + ) : ( + <> + — + + )} +
+
+ ) : undefined +} diff --git a/src/components/FilesUsers.tsx/style.module.scss b/src/components/FilesUsers.tsx/style.module.scss new file mode 100644 index 0000000..b6e0313 --- /dev/null +++ b/src/components/FilesUsers.tsx/style.module.scss @@ -0,0 +1,41 @@ +@import '../../styles/colors.scss'; + +.container { + border-radius: 4px; + background: $overlay-background-color; + padding: 15px; + display: flex; + flex-direction: column; + grid-gap: 25px; + + font-size: 14px; +} + +.section { + display: flex; + flex-direction: column; + grid-gap: 10px; +} + +.detailsItem { + transition: ease 0.2s; + color: rgba(0, 0, 0, 0.5); + font-size: 14px; + align-items: center; + border-radius: 4px; + padding: 5px; + + display: flex; + align-items: center; + justify-content: start; + + > :first-child { + padding: 5px; + margin-right: 10px; + } + + &:hover { + background: $primary-main; + color: white; + } +} diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index 5460983..78516d5 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -4,7 +4,7 @@ import { Mark } from '../types/mark' import { fromUnixTimestamp, hexToNpub, - parseCreateSignatureEvent, + parseNostrEvent, parseCreateSignatureEventContent, SigitMetaParseError, SigitStatus, @@ -21,7 +21,7 @@ import { NostrController } from '../controllers' * Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`, * and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions) */ -interface FlatMeta +export interface FlatMeta extends Meta, CreateSignatureEventContent, Partial> { @@ -37,6 +37,12 @@ interface FlatMeta // Decryption encryptionKey: string | null + // Parsed Document Signatures + parsedSignatureEvents: { [signer: `npub1${string}`]: Event } + + // Calculated completion time + completedAt?: number + // Calculated status fields signedStatus: SigitStatus signersStatus: { @@ -67,6 +73,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { const [title, setTitle] = useState('') const [zipUrl, setZipUrl] = useState('') + const [parsedSignatureEvents, setParsedSignatureEvents] = useState<{ + [signer: `npub1${string}`]: Event + }>({}) + + const [completedAt, setCompletedAt] = useState() + const [signedStatus, setSignedStatus] = useState( SigitStatus.Partial ) @@ -80,9 +92,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { if (!meta) return ;(async function () { try { - const createSignatureEvent = await parseCreateSignatureEvent( - meta.createSignature - ) + const createSignatureEvent = await parseNostrEvent(meta.createSignature) const { kind, tags, created_at, pubkey, id, sig, content } = createSignatureEvent @@ -131,13 +141,22 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { } } - // Parse each signature event and set signer status + // Temp. map to hold events + const parsedSignatureEventsMap = new Map<`npub1${string}`, Event>() for (const npub in meta.docSignatures) { try { - const event = await parseCreateSignatureEvent( + // Parse each signature event + const event = await parseNostrEvent( meta.docSignatures[npub as `npub1${string}`] ) + const isValidSignature = verifyEvent(event) + + // Save events to a map, to save all at once outside loop + // We need the object to find completedAt + // Avoided using parsedSignatureEvents due to useEffect deps + parsedSignatureEventsMap.set(npub as `npub1${string}`, event) + setSignersStatus((prev) => { return { ...prev, @@ -155,6 +174,11 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { }) } } + + setParsedSignatureEvents( + Object.fromEntries(parsedSignatureEventsMap.entries()) + ) + const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[] const isCompletelySigned = signers.every((signer) => signedBy.includes(signer) @@ -162,6 +186,20 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setSignedStatus( isCompletelySigned ? SigitStatus.Complete : SigitStatus.Partial ) + + // Check if all signers signed and are valid + if (isCompletelySigned) { + setCompletedAt( + fromUnixTimestamp( + signedBy.reduce((p, c) => { + return Math.max( + p, + parsedSignatureEventsMap.get(c)?.created_at || 0 + ) + }, 0) + ) + ) + } } catch (error) { if (error instanceof SigitMetaParseError) { toast.error(error.message) @@ -189,6 +227,8 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { markConfig, title, zipUrl, + parsedSignatureEvents, + completedAt, signedStatus, signersStatus, encryptionKey diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index f77bd48..d6a297d 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -1,36 +1,20 @@ -import { - Box, - Button, - List, - ListItem, - ListSubheader, - Tooltip, - Typography, - useTheme -} from '@mui/material' +import { Box, Button, Tooltip, Typography, useTheme } from '@mui/material' import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' import { Event, verifyEvent } from 'nostr-tools' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' -import { UserAvatar } from '../../components/UserAvatar' import { NostrController } from '../../controllers' -import { - CreateSignatureEventContent, - Meta, - SignedEventContent -} from '../../types' +import { CreateSignatureEventContent, Meta } from '../../types' import { decryptArrayBuffer, extractMarksFromSignedMeta, getHash, hexToNpub, unixNow, - npubToHex, parseJson, readContentOfZipEntry, - shorten, signEventForMetaFile } from '../../utils' import styles from './style.module.scss' @@ -50,7 +34,8 @@ import { getLastSignersSig } from '../../utils/sign.ts' import { saveAs } from 'file-saver' import { Container } from '../../components/Container' import { useSigitMeta } from '../../hooks/useSigitMeta.tsx' -import { useSigitProfiles } from '../../hooks/useSigitProfiles.tsx' +import { Files } from '../../layouts/Files.tsx' +import { FileUsers } from '../../components/FilesUsers.tsx/index.tsx' export const VerifyPage = () => { const theme = useTheme() @@ -67,11 +52,6 @@ export const VerifyPage = () => { const { submittedBy, zipUrl, encryptionKey, signers, viewers, fileHashes } = useSigitMeta(meta) - const profiles = useSigitProfiles([ - ...(submittedBy ? [submittedBy] : []), - ...signers, - ...viewers - ]) const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') @@ -283,35 +263,6 @@ export const VerifyPage = () => { setIsLoading(false) } - const getPrevSignersSig = (npub: string) => { - if (!meta) return null - - // if user is first signer then use creator's signature - if (signers[0] === npub) { - try { - const createSignatureEvent: Event = JSON.parse(meta.createSignature) - return createSignatureEvent.sig - } catch (error) { - return null - } - } - - // find the index of signer - const currentSignerIndex = signers.findIndex((signer) => signer === npub) - // return null if could not found user in signer's list - if (currentSignerIndex === -1) return null - // find prev signer - const prevSigner = signers[currentSignerIndex - 1] - - // get the signature of prev signer - try { - const prevSignersEvent: Event = JSON.parse(meta.docSignatures[prevSigner]) - return prevSignersEvent.sig - } catch (error) { - return null - } - } - const handleExport = async () => { if (Object.entries(files).length === 0 || !meta || !usersPubkey) return @@ -379,76 +330,6 @@ export const VerifyPage = () => { setIsLoading(false) } - const displayUser = (pubkey: string, verifySignature = false) => { - const profile = profiles[pubkey] - - let isValidSignature = false - - if (verifySignature) { - const npub = hexToNpub(pubkey) - const signedEventString = meta ? meta.docSignatures[npub] : null - if (signedEventString) { - try { - const signedEvent = JSON.parse(signedEventString) - const isVerifiedEvent = verifyEvent(signedEvent) - - if (isVerifiedEvent) { - // get the actual signature of prev signer - const prevSignersSig = getPrevSignersSig(npub) - - // get the signature of prev signer from the content of current signers signedEvent - - try { - const obj: SignedEventContent = JSON.parse(signedEvent.content) - if ( - obj.prevSig && - prevSignersSig && - obj.prevSig === prevSignersSig - ) { - isValidSignature = true - } - } catch (error) { - isValidSignature = false - } - } - } catch (error) { - console.error( - `An error occurred in parsing and verifying the signature event for ${pubkey}`, - error - ) - } - } - } - - return ( - <> - - - {verifySignature && ( - <> - {isValidSignature && ( - - - - )} - - {!isValidSignature && ( - - - - )} - - )} - - ) - } - const displayExportedBy = () => { if (!meta || !meta.exportSignature) return null @@ -458,7 +339,7 @@ export const VerifyPage = () => { const exportSignatureEvent = JSON.parse(exportSignatureString) as Event if (verifyEvent(exportSignatureEvent)) { - return displayUser(exportSignatureEvent.pubkey) + // return displayUser(exportSignatureEvent.pubkey) } else { toast.error(`Invalid export signature!`) return ( @@ -505,109 +386,9 @@ export const VerifyPage = () => { )} {meta && ( - <> - - Meta Info - - } - > - {submittedBy && ( - - - Submitted By - - {displayUser(submittedBy)} - - )} - - - - Exported By - - {displayExportedBy()} - - - - - - {signers.length > 0 && ( - - - Signers - -
    - {signers.map((signer) => ( -
  • - {displayUser(npubToHex(signer)!, true)} -
  • - ))} -
-
- )} - - {viewers.length > 0 && ( - - - Viewers - -
    - {viewers.map((viewer) => ( -
  • - {displayUser(npubToHex(viewer)!)} -
  • - ))} -
-
- )} - - - - Files - + {Object.entries(currentFileHashes).map( ([filename, hash], index) => { @@ -643,9 +424,17 @@ export const VerifyPage = () => { } )} - -
- + {displayExportedBy()} + + + + + } + right={} + content={
} + /> )} diff --git a/src/utils/meta.ts b/src/utils/meta.ts index 74d38b7..dd29b60 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -62,7 +62,7 @@ function handleError(error: unknown): Error { // Reuse common error messages for meta parsing export enum SigitMetaParseErrorType { - 'PARSE_ERROR_SIGNATURE_EVENT' = 'error occurred in parsing the create signature event', + 'PARSE_ERROR_EVENT' = 'error occurred in parsing the create signature event', 'PARSE_ERROR_SIGNATURE_EVENT_CONTENT' = "err in parsing the createSignature event's content" } @@ -76,24 +76,19 @@ export interface SigitCardDisplayInfo { } /** - * Wrapper for createSignatureEvent parse that throws custom SigitMetaParseError with cause and context + * Wrapper for event parser that throws custom SigitMetaParseError with cause and context * @param raw Raw string for parsing * @returns parsed Event */ -export const parseCreateSignatureEvent = async ( - raw: string -): Promise => { +export const parseNostrEvent = async (raw: string): Promise => { try { - const createSignatureEvent = await parseJson(raw) - return createSignatureEvent + const event = await parseJson(raw) + return event } catch (error) { - throw new SigitMetaParseError( - SigitMetaParseErrorType.PARSE_ERROR_SIGNATURE_EVENT, - { - cause: handleError(error), - context: raw - } - ) + throw new SigitMetaParseError(SigitMetaParseErrorType.PARSE_ERROR_EVENT, { + cause: handleError(error), + context: raw + }) } } @@ -135,9 +130,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { } try { - const createSignatureEvent = await parseCreateSignatureEvent( - meta.createSignature - ) + const createSignatureEvent = await parseNostrEvent(meta.createSignature) // created_at in nostr events are stored in seconds sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at) @@ -147,13 +140,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { ) const files = Object.keys(createSignatureContent.fileHashes) - const extensions = files.reduce((result: string[], file: string) => { - const extension = file.split('.').pop() - if (extension) { - result.push(extension) - } - return result - }, []) + const extensions = extractFileExtensions(files) const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[] const isCompletelySigned = createSignatureContent.signers.every((signer) => @@ -179,3 +166,15 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { } } } + +export const extractFileExtensions = (fileNames: string[]) => { + const extensions = fileNames.reduce((result: string[], file: string) => { + const extension = file.split('.').pop() + if (extension) { + result.push(extension) + } + return result + }, []) + + return extensions +}